From dde7220e67a27df4fa941c22a0a98bea4da3895a Mon Sep 17 00:00:00 2001 From: Emmanuel Varagnat Date: Wed, 5 Nov 2025 16:41:32 +0100 Subject: [PATCH 1/9] Upgrade pyright to 1.1.407 Signed-off-by: Emmanuel Varagnat --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index d7bfc6808..75c6f68cd 100644 --- a/uv.lock +++ b/uv.lock @@ -479,15 +479,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.402" +version = "1.1.407" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207, upload-time = "2025-06-11T08:48:35.759Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004, upload-time = "2025-06-11T08:48:33.998Z" }, + { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, ] [[package]] From 5844e400ec9cf9a6ee9f6d60bcc486a73fecfd47 Mon Sep 17 00:00:00 2001 From: Emmanuel Varagnat Date: Tue, 4 Nov 2025 13:59:49 +0100 Subject: [PATCH 2/9] Collect logs on test failure Signed-off-by: Emmanuel Varagnat --- conftest.py | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/conftest.py b/conftest.py index caf476684..e0f335c0a 100644 --- a/conftest.py +++ b/conftest.py @@ -2,6 +2,7 @@ import pytest +import datetime import itertools import logging import os @@ -45,6 +46,13 @@ CACHE_IMPORTED_VM = False assert CACHE_IMPORTED_VM in [True, False] +# Session-level tracking for failed tests and their associated hosts +FAILED_TESTS_HOSTS = {} # {test_nodeid: set(Host)} +SESSION_HAS_FAILURES = False + +# Path to timestamp file on remote hosts for log extraction +HOST_TIMESTAMPS_FILE = "/tmp/pytest-timestamps.log" + # pytest hooks def pytest_addoption(parser): @@ -99,6 +107,18 @@ def pytest_addoption(parser): help="Format of VDI to execute tests on." "Example: vhd,qcow2" ) + parser.addoption( + "--collect-logs-on-failure", + action="store_true", + default=True, + help="Automatically collect xen-bugtool logs from hosts when tests fail (default: True)" + ) + parser.addoption( + "--no-collect-logs-on-failure", + action="store_false", + dest="collect_logs_on_failure", + help="Disable automatic log collection on test failure" + ) def pytest_configure(config): global_config.ignore_ssh_banner = config.getoption('--ignore-ssh-banner') @@ -760,3 +780,171 @@ def cifs_iso_sr(host, cifs_iso_device_config): yield sr # teardown sr.forget() + + +# Session-scoped fixture to create a shared log directory for all artifacts +@pytest.fixture(scope='session') +def session_log_dir(): + """ + Create and return a session-wide log directory for storing all test artifacts. + The directory is created at session start and shared by all fixtures. + + Directory naming includes BUILD_NUMBER if running in Jenkins CI environment. + """ + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + # BUILD_NUMBER and WORKSPACE are specific to Jenkins + build_number = os.environ.get('BUILD_NUMBER') + if build_number: + logging.debug(f"Use Jenkins CI build number: {build_number}") + workspace = os.environ.get('WORKSPACE') + log_dir = f"{workspace}/report_{build_number}" + else: + log_dir = f"pytest-logs/report_{timestamp}" + + # postpone directory creation only if some test failed + return log_dir + + +# Record test timestamps to easily extract logs +@pytest.fixture(scope='class', autouse=True) +def bugreport_timestamp(request, host): + class_name = request.module.__name__ + logging.debug(f"Record timestamp (begin): {class_name}") + host.ssh(f"echo begin {class_name} $(date '+%s') >> {HOST_TIMESTAMPS_FILE}") + yield + logging.debug(f"Record timestamp (end): {class_name}") + host.ssh(f"echo end {class_name} $(date '+%s') >> {HOST_TIMESTAMPS_FILE}") + + +def _introspect_test_fixtures(request): + """ + Introspect test fixtures to find Host objects. + Returns test_hosts set. + """ + # Collect all Host and VM objects used by this test from its fixtures + test_hosts = set() + + # Check all fixtures used by this test + # XXX: this is a bit hard to understand but this introspection of fixtures + # is the best way to gather VMs and Hosts. Other alternatives require, + # maintenance, boilerplate and are more prone to miss some cases. + for fixturename in getattr(request, 'fixturenames', []): + try: + fixture_value = request.getfixturevalue(fixturename) + # Check if fixture is a Host + if isinstance(fixture_value, Host): + test_hosts.add(fixture_value) + # Check if fixture is a list of hosts + elif isinstance(fixture_value, list) and all(isinstance(h, Host) for h in fixture_value): + test_hosts.update(fixture_value) + # Check if fixture is a dict + elif isinstance(fixture_value, dict): + logging.warning("dictionnary in fixtures case not handled") + # Check if fixture has a 'host' attribute (like SR, Pool objects) + elif hasattr(fixture_value, 'host') and isinstance(fixture_value.host, Host): + test_hosts.add(fixture_value.host) + # Check if fixture is a Pool + elif hasattr(fixture_value, 'hosts') and isinstance(fixture_value.hosts, list): + test_hosts.update(h for h in fixture_value.hosts if isinstance(h, Host)) + except Exception: + # Some fixtures may not be available yet or may fail to load + pass + + return test_hosts + +@pytest.fixture(scope='function', autouse=True) +def track_test_host(request, session_log_dir): + """ + Track which hosts are used by each test. + On test failure, record the test and its hosts for later log collection and console capture. + """ + global FAILED_TESTS_HOSTS, SESSION_HAS_FAILURES + + yield + + # After test completes, check if it failed + report = request.node.stash.get(PHASE_REPORT_KEY, None) + if report and "call" in report and report["call"].failed: + test_nodeid = request.node.nodeid + + # Only introspect fixtures if test failed (performance optimization) + test_hosts = _introspect_test_fixtures(request) + + logging.warning(f"Test failed: {test_nodeid}, " + f"used hosts: {[h.hostname_or_ip for h in test_hosts]}") + + if test_hosts: + FAILED_TESTS_HOSTS[test_nodeid] = test_hosts + SESSION_HAS_FAILURES = True + +@pytest.fixture(scope='session', autouse=True) +def collect_logs_on_session_failure(request, hosts, session_log_dir): + """ + Collect xen-bugtool logs from hosts at the end of the test session if any tests failed. + Only collects from hosts that were actually used by failed tests. + """ + global FAILED_TESTS_HOSTS, SESSION_HAS_FAILURES + + yield # Let all tests run first + + # Check if log collection is enabled + collect_logs = request.config.getoption("--collect-logs-on-failure", default=True) + if not collect_logs: + return + + if not SESSION_HAS_FAILURES and not FAILED_TESTS_HOSTS: + return + + # Get unique set of hosts that had failures + failed_hosts = set() + for test_hosts in FAILED_TESTS_HOSTS.values(): + failed_hosts.update(test_hosts) + + if not failed_hosts: + logging.debug("Tests failed but no hosts were tracked. Collecting logs from all configured hosts.") + failed_hosts = set(hosts) + + logging.debug("Collecting logs from {len(failed_hosts)} host(s) due to test failures...") + + # Use the session-wide log directory (already created by session_log_dir fixture) + # This ensures logs are saved alongside console captures + log_dir = session_log_dir + os.makedirs(log_dir, exist_ok=True) + + for host in failed_hosts: + try: + logging.debug(f"Collecting logs from host {host.hostname_or_ip}...") + + # Run xen-bugtool on the host + result = host.ssh(['xen-bugtool', '-y', '-s'], check=True) + filepath = result.strip() + assert filepath.endswith('.tar.bz2'), f"expect a .tar.bz2 archive: {filepath}" + + filename = os.path.basename(filepath) + + # Construct local filename with host identifier + local_filename = f"{host.hostname_or_ip.replace(':', '_')}_{filename}" + local_path = os.path.join(log_dir, local_filename) + + # Download the archive from host + logging.debug(f"Downloading {filepath} to {local_path}...") + host.scp(filepath, local_path, local_dest=True, check=True) + + # Download the timestamp file if it exists + local_timestamp_filename = f"{host.hostname_or_ip.replace(':', '_')}_pytest-timestamps.log" + local_timestamp_path = os.path.join(log_dir, local_timestamp_filename) + try: + host.scp(HOST_TIMESTAMPS_FILE, local_timestamp_path, local_dest=True, check=False) + logging.debug(f"Timestamp file downloaded to: {local_timestamp_path}") + except Exception as ts_e: + logging.debug(f"Could not download timestamp file (may not exist): {ts_e}") + + # Clean up archive from host + logging.info(f"Cleaning up {filepath} from host...") + host.ssh(['rm', '-f', filepath]) + + except Exception as e: + logging.error(f"Failed to collect logs from host {host.hostname_or_ip}: {e}") + + logging.info(f"Log collection complete. Logs saved to: {log_dir}") From ec04550d2ac6ab4563b7eea0f34d64ec9cb29d11 Mon Sep 17 00:00:00 2001 From: Emmanuel Varagnat Date: Wed, 5 Nov 2025 17:15:45 +0100 Subject: [PATCH 3/9] lib: check presence of too much xen-bugtool reports Signed-off-by: Emmanuel Varagnat --- conftest.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/conftest.py b/conftest.py index e0f335c0a..223b535df 100644 --- a/conftest.py +++ b/conftest.py @@ -816,6 +816,16 @@ def bugreport_timestamp(request, host): logging.debug(f"Record timestamp (end): {class_name}") host.ssh(f"echo end {class_name} $(date '+%s') >> {HOST_TIMESTAMPS_FILE}") +@pytest.fixture(scope='session', autouse=True) +def check_bug_reports(host): + """ + That could be the sign of interrupted tests and some cleanup might be + required. We don't want to accumulate too much of these files. + """ + output = host.ssh("find /var/opt/xen/bug-report/ -type f 2> /dev/null | wc -l") + count = int(output) + if count > 3: # arbitrary number; this should let a developer work on that host without warnings + logging.warning(f"Cleanup needed. {count} bug report(s) have been found on host.") def _introspect_test_fixtures(request): """ From f178db06434a4f742cbc91f5d7dc69ad6b9b1aff Mon Sep 17 00:00:00 2001 From: Emmanuel Varagnat Date: Wed, 5 Nov 2025 17:16:44 +0100 Subject: [PATCH 4/9] lib: use option to put ssh in background And not solely rely on subprocess and early quit. Signed-off-by: Emmanuel Varagnat --- lib/commands.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/commands.py b/lib/commands.py index 24ec1cb07..53d2ab71a 100644 --- a/lib/commands.py +++ b/lib/commands.py @@ -73,6 +73,8 @@ def _ssh(hostname_or_ip, cmd, check, simple_output, suppress_fingerprint_warning opts.append('-o "StrictHostKeyChecking no"') opts.append('-o "LogLevel ERROR"') opts.append('-o "UserKnownHostsFile /dev/null"') + if background: + opts.append('-f') if isinstance(cmd, str): command = cmd From c19542bd0f0b605c504a12ec743e4736bac32e3d Mon Sep 17 00:00:00 2001 From: Emmanuel Varagnat Date: Tue, 4 Nov 2025 16:28:44 +0100 Subject: [PATCH 5/9] Capture VM console for some tests Screenshot of the VM screen will be saved for failed tests, if marked with pytest.mark.capture_console. This is based on new script capture-console.py that connect to XO-lite for capture. Signed-off-by: Emmanuel Varagnat --- conftest.py | 105 +++++++++++++-- pyproject.toml | 4 + requirements/base.txt | 3 + scripts/README.md | 70 ++++++++++ scripts/capture-console.py | 264 +++++++++++++++++++++++++++++++++++++ uv.lock | 240 ++++++++++++++++++++++++++++++++- 6 files changed, 671 insertions(+), 15 deletions(-) create mode 100644 scripts/README.md create mode 100755 scripts/capture-console.py diff --git a/conftest.py b/conftest.py index 223b535df..903683048 100644 --- a/conftest.py +++ b/conftest.py @@ -6,6 +6,7 @@ import itertools import logging import os +import sys import tempfile import git @@ -13,6 +14,7 @@ import lib.config as global_config from lib import pxe +from lib import commands from lib.common import ( callable_marker, DiskDevName, @@ -46,8 +48,9 @@ CACHE_IMPORTED_VM = False assert CACHE_IMPORTED_VM in [True, False] -# Session-level tracking for failed tests and their associated hosts +# Session-level tracking for failed tests and their associated hosts/VMs FAILED_TESTS_HOSTS = {} # {test_nodeid: set(Host)} +FAILED_TESTS_VMS = {} # {test_nodeid: set(VM)} SESSION_HAS_FAILURES = False # Path to timestamp file on remote hosts for log extraction @@ -124,6 +127,12 @@ def pytest_configure(config): global_config.ignore_ssh_banner = config.getoption('--ignore-ssh-banner') global_config.ssh_output_max_lines = int(config.getoption('--ssh-output-max-lines')) + # Register custom markers + config.addinivalue_line( + "markers", + "capture_console: Capture VM console screenshots on test failure (opt-in for critical tests)" + ) + def pytest_generate_tests(metafunc): if "vm_ref" in metafunc.fixturenames: vms = metafunc.config.getoption("vm") @@ -781,7 +790,6 @@ def cifs_iso_sr(host, cifs_iso_device_config): # teardown sr.forget() - # Session-scoped fixture to create a shared log directory for all artifacts @pytest.fixture(scope='session') def session_log_dir(): @@ -805,6 +813,47 @@ def session_log_dir(): # postpone directory creation only if some test failed return log_dir +# Helper function for immediate console capture +def capture_vm_console(vm: VM, log_dir: str) -> str: + + # Path to console capture Python script + script_path = os.path.join(os.path.dirname(__file__), 'scripts', 'capture-console.py') + if not os.path.exists(script_path): + raise FileNotFoundError(f"Console capture script not found at {script_path}") + + host_ip = vm.host.hostname_or_ip + vm_uuid = vm.uuid + + try: + vm_name = vm.name() if hasattr(vm, 'name') and callable(vm.name) else vm_uuid[:8] + except Exception: + vm_name = vm_uuid[:8] + + # Generate output filename with VM info + output_filename = f"{host_ip.replace(':', '_')}_vm_{vm_name}_{vm_uuid[:8]}_console.png" + output_path = os.path.join(log_dir, output_filename) + + logging.debug(f"Capturing console for VM {vm_name} ({vm_uuid}) in {output_path}...") + + # Get credentials from data.py + from data import HOST_DEFAULT_PASSWORD, HOST_DEFAULT_USER + + # Build command to call Python script directly (XOLite mode) + cmd = [ + sys.executable, # Use same Python interpreter as current process + script_path, + '--host', host_ip, + '--vm-uuid', vm_uuid, + output_filename, + '--output-dir', log_dir, + '--user', HOST_DEFAULT_USER, + '--password', HOST_DEFAULT_PASSWORD + ] + + # Run the capture script + result = commands.local_cmd(cmd, check=True) + + return output_path # Record test timestamps to easily extract logs @pytest.fixture(scope='class', autouse=True) @@ -829,11 +878,12 @@ def check_bug_reports(host): def _introspect_test_fixtures(request): """ - Introspect test fixtures to find Host objects. - Returns test_hosts set. + Introspect test fixtures to find Host and VM objects. + Returns (test_hosts, test_vms) sets. """ # Collect all Host and VM objects used by this test from its fixtures test_hosts = set() + test_vms = set() # Check all fixtures used by this test # XXX: this is a bit hard to understand but this introspection of fixtures @@ -845,9 +895,18 @@ def _introspect_test_fixtures(request): # Check if fixture is a Host if isinstance(fixture_value, Host): test_hosts.add(fixture_value) - # Check if fixture is a list of hosts - elif isinstance(fixture_value, list) and all(isinstance(h, Host) for h in fixture_value): - test_hosts.update(fixture_value) + # Check if fixture is a VM + elif isinstance(fixture_value, VM): + test_vms.add(fixture_value) + test_hosts.add(fixture_value.host) + # Check if fixture is a list + elif isinstance(fixture_value, list): + for item in fixture_value: + if isinstance(item, Host): + test_hosts.add(item) + elif isinstance(item, VM): + test_vms.add(item) + test_hosts.add(item.host) # Check if fixture is a dict elif isinstance(fixture_value, dict): logging.warning("dictionnary in fixtures case not handled") @@ -861,15 +920,15 @@ def _introspect_test_fixtures(request): # Some fixtures may not be available yet or may fail to load pass - return test_hosts + return test_hosts, test_vms @pytest.fixture(scope='function', autouse=True) def track_test_host(request, session_log_dir): """ - Track which hosts are used by each test. - On test failure, record the test and its hosts for later log collection and console capture. + Track which hosts and VMs are used by each test. + On test failure, record the test and its hosts/VMs for later log collection and console capture. """ - global FAILED_TESTS_HOSTS, SESSION_HAS_FAILURES + global FAILED_TESTS_HOSTS, FAILED_TESTS_VMS, SESSION_HAS_FAILURES yield @@ -879,14 +938,32 @@ def track_test_host(request, session_log_dir): test_nodeid = request.node.nodeid # Only introspect fixtures if test failed (performance optimization) - test_hosts = _introspect_test_fixtures(request) + test_hosts, test_vms = _introspect_test_fixtures(request) - logging.warning(f"Test failed: {test_nodeid}, " - f"used hosts: {[h.hostname_or_ip for h in test_hosts]}") + logging.debug(f"Test failed: {test_nodeid}, " + f"used hosts: {[h.hostname_or_ip for h in test_hosts]}, " + f"VMs: {[vm.uuid for vm in test_vms]}") if test_hosts: FAILED_TESTS_HOSTS[test_nodeid] = test_hosts SESSION_HAS_FAILURES = True + if test_vms: + FAILED_TESTS_VMS[test_nodeid] = test_vms + + # Check if test/class has capture_console marker for console screenshots + has_console_marker = request.node.get_closest_marker("capture_console") is not None + if has_console_marker: + logging.debug("Capturing console NOW...") + + # Capture console for each VM immediately (while VM is still alive) + # Use the session-wide log directory + for vm in test_vms: + try: + os.makedirs(session_log_dir, exist_ok=True) + screenshot_path = capture_vm_console(vm, session_log_dir) + logging.debug(f"Console captured: {screenshot_path}") + except Exception as e: + logging.error(f"Failed to capture console for VM {vm.uuid}: {e}") @pytest.fixture(scope='session', autouse=True) def collect_logs_on_session_failure(request, hosts, session_log_dir): diff --git a/pyproject.toml b/pyproject.toml index 7602554fa..70d9b6c11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,10 @@ dependencies = [ "pytest>=8.0.0", "pytest-dependency", "requests", + # For console capture + "websockets", + "Pillow", + "asyncvnc2", ] [dependency-groups] diff --git a/requirements/base.txt b/requirements/base.txt index 38b1c68c8..a44f2b42a 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -7,3 +7,6 @@ pluggy>=1.1.0 pytest>=8.0.0 pytest-dependency requests +websockets +Pillow +asyncvnc2 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..3a7a404fa --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,70 @@ +# Overview + +This directory contains utility scripts for XCP-ng test automation. + +# capture-console.py + +Captures VM console screenshots from XO-lite (default) or XOA, via VNC over WebSocket. +The script creates a local WebSocket-to-TCP proxy that handles authentication (either XAPI session tokens +or browser cookies) and connects to the VM's VNC console. +It then uses asyncvnc2 to capture a screenshot and save it as a PNG file. + +## XOLite Mode (XAPI Session Authentication) + +Used for capturing console screenshots from XCP-ng hosts directly via XAPI. + +**Usage:** +```bash +python3 capture-console.py --host --vm-uuid [output_file] [options] +``` + +**Examples:** +```bash +# Using environment variables for credentials (default filename: screenshot.png) +export HOST_DEFAULT_USER="root" +export HOST_DEFAULT_PASSWORD="password" +python3 capture-console.py --host 192.168.1.100 --vm-uuid a1b2c3d4-1234-5678-90ab-cdef12345678 + +# Custom filename +python3 capture-console.py --host 192.168.1.100 --vm-uuid a1b2c3d4-1234-5678-90ab-cdef12345678 \ + my_screenshot.png + +# Custom filename with output directory +python3 capture-console.py --host 192.168.1.100 --vm-uuid a1b2c3d4-1234-5678-90ab-cdef12345678 \ + vm_console.png --output-dir /tmp/logs + +# Using command-line options to override credentials +python3 capture-console.py --host 192.168.1.100 --vm-uuid a1b2c3d4-1234-5678-90ab-cdef12345678 \ + --user root --password mypassword --output-dir /tmp/logs +``` + +## XOA Mode (Cookie-Based Authentication) + +Used for capturing console screenshots from Xen Orchestra Appliance (XOA) web interface. +You must supply the cookie, that you can get from your browser for instance. + +**Usage:** +```bash +python3 capture-console.py [output_file] --cookie +``` + +**Example:** +```bash +python3 capture-console.py \ + wss://xoa.example.com/api/consoles/0377d240-dcd5-bfe0-f2ea-878853f8f1dc \ + screenshot.png \ + --cookie "connect.sid=s%3AK2UCcIuGdDd0...; clientId=pjbvcidrbta; token=198jIZV5rjyQfQ..." +``` + +## BUILD_NUMBER *(Jenkins-specific)* +Build number assigned by Jenkins CI will be used to name the default directory, following that scheme: +`/tmp/pytest-logs/session_{timestamp}_build_{BUILD_NUMBER}/` + +# extract_logs.py + +# install_xcpng.py + +# get_xva_bridge.sh / set_xva_bridge.sh + +# xcpng-fs-diff.py + diff --git a/scripts/capture-console.py b/scripts/capture-console.py new file mode 100755 index 000000000..65e8c0dac --- /dev/null +++ b/scripts/capture-console.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +VNC WebSocket Proxy - Capture screen from VNC over WebSocket + +This script acts as a WebSocket-to-TCP proxy for VNC connections, handling authentication +and proxying the connection to a local TCP port where a VNC library can connect. + +Supports two modes: +1. XOA mode: Uses cookie-based authentication (--cookie) +2. XOLite mode: Uses XAPI session authentication (--host, --vm-uuid, --user, --password) + +Usage: + XOA: python3 capture-console.py [output_file] --cookie + XOLite: python3 capture-console.py --host --vm-uuid [output_file] [--user ] [--password ] + +Environment variables: + HOST_DEFAULT_USER: Default username for XAPI authentication (can be overridden by --user) + HOST_DEFAULT_PASSWORD: Default password for XAPI authentication (can be overridden by --password) + +Dependencies: + - websockets: WebSocket client + - asyncvnc2: VNC client library with ZRLE support + - Pillow (PIL): Image processing +""" + +import argparse +import asyncio +import json +import logging +import os +import ssl +import sys +import traceback +import urllib.request + +import asyncvnc2 +import websockets +from PIL import Image + +async def websocket_to_tcp_proxy(ws_url, cookie=None): + + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + additional_headers = {'Cookie': cookie} if cookie else {} + + async def handle_client(reader, writer): + + logging.debug(f"Client connected from {writer.get_extra_info('peername')}") + + try: + # Connect to WebSocket VNC server + async with websockets.connect( + ws_url, + subprotocols=['binary'], + ssl=ssl_context, + additional_headers=additional_headers + ) as websocket: + logging.info(f"Connected to WebSocket VNC server: {ws_url}") + + async def ws_to_tcp(): + async for message in websocket: + writer.write(message) + await writer.drain() + logging.debug(f"WS→TCP: {len(message)} bytes") + + async def tcp_to_ws(): + while True: + data = await reader.read(8192) + if not data: + break + await websocket.send(data) + logging.debug(f"TCP→WS: {len(data)} bytes") + + # Run both directions concurrently until one completes + await asyncio.gather(ws_to_tcp(), tcp_to_ws(), return_exceptions=True) + + except Exception as e: + logging.debug(f"Proxy error: {e}") + finally: + logging.debug("Proxy stopped") + writer.close() + await writer.wait_closed() + + # Start TCP server with port 0 to let OS pick a random available port + server = await asyncio.start_server(handle_client, '127.0.0.1', 0) + + local_port = server.sockets[0].getsockname()[1] + + logging.info(f"Proxy listening on 127.0.0.1:{local_port}") + + return local_port, server + + +async def capture_vnc_screenshot(local_port, output_file): + + logging.info(f"Connecting VNC client to 127.0.0.1:{local_port}") + + async with asyncvnc2.connect('127.0.0.1', local_port) as client: + logging.info("VNC client connected, capturing screenshot...") + + pixels = await client.screenshot() + + image = Image.fromarray(pixels) + image.save(output_file) + logging.info(f"Screenshot saved to {output_file}") + + +def xapi_authenticate(host_ip, username, password): + + payload = { + "jsonrpc": "2.0", + "id": 2, + "method": "session.login_with_password", + "params": [username, password] + } + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + req = urllib.request.Request( + f"https://{host_ip}/jsonrpc", + data=json.dumps(payload).encode('utf-8'), + headers={'Content-Type': 'application/json'} + ) + + with urllib.request.urlopen(req, context=ssl_context) as response: + result = json.loads(response.read().decode('utf-8')) + if 'result' in result: + return result['result'] + raise Exception(f"XAPI authentication failed: {result.get('error', result)}") + + +async def main(): + # Create argument parser with custom usage message + parser = argparse.ArgumentParser( + description='VNC WebSocket Console Capture', + formatter_class=argparse.RawDescriptionHelpFormatter, + usage=""" + XOA mode (cookie-based authentication): + %(prog)s [output_file] --cookie + + XOLite mode (XAPI session authentication): + %(prog)s --host --vm-uuid [output_file] [--user ] [--password ] +""", + epilog=""" +Examples: + XOLite: %(prog)s --host 192.168.1.100 --vm-uuid abc-123-def screenshot.png + XOA: %(prog)s wss://xoa.example.com/api/consoles/xxx --cookie "..." + +Environment variables: + HOST_DEFAULT_USER Default username for XAPI authentication + HOST_DEFAULT_PASSWORD Default password for XAPI authentication + """ + ) + + # Positional arguments + # First positional argument can be either URL in XOA mode, or output_file in + # XOLite mode. + parser.add_argument('url_or_filename', nargs='?', help=argparse.SUPPRESS) + # Second positional argument only used in XOLite mode + parser.add_argument('filename', nargs='?', help=argparse.SUPPRESS) + + # XOLite mode options + parser.add_argument('--host', help="Host IP for XOLite mode") + parser.add_argument('--vm-uuid', help="VM UUID for XOLite mode") + parser.add_argument('--user', help="Username (default: HOST_DEFAULT_USER env var or 'root')") + parser.add_argument('--password', help="Password (default: HOST_DEFAULT_PASSWORD env var)") + + # XOA mode options + parser.add_argument('--cookie', help="Cookie for XOA authentication") + + # Common options + parser.add_argument('--output-dir', dest='output_dir', help="Output directory for screenshot") + parser.add_argument('--verbose', '-v', action='store_true', help="Enable verbose output") + + args = parser.parse_args() + + # Configure logging based on verbose flag + if args.verbose: + logging.basicConfig(level=logging.DEBUG, format='%(message)s') + else: + logging.basicConfig(level=logging.WARNING, format='%(message)s') + + # Determine mode and validate arguments + if args.cookie: + # XOA mode: cookie-based authentication + if not args.url_or_filename: + print("Error: WebSocket URL required for XOA mode") + print() + print("Usage: python3 capture-console.py [output_file] --cookie ") + sys.exit(1) + + ws_url = args.url_or_filename + output_file = args.filename if args.filename else "screenshot.png" + cookie = args.cookie + logging.info("Using XOA mode (cookie-based authentication)") + + elif args.host and args.vm_uuid: + # XOLite mode: XAPI session authentication + logging.info("Using XOLite mode (XAPI session authentication)") + + # Get credentials from args or environment variables + username = args.user if args.user else os.environ.get('HOST_DEFAULT_USER', 'root') + password = args.password if args.password else os.environ.get('HOST_DEFAULT_PASSWORD', '') + + # Output filename from positional arg or default + output_file = args.url_or_filename if args.url_or_filename else "screenshot.png" + + logging.info(f"Authenticating with XAPI at {args.host}...") + try: + session_id = xapi_authenticate(args.host, username, password) + logging.info(f"Authentication successful, session ID: {session_id[:20]}...") + except Exception as e: + print(f"Authentication failed: {e}") + sys.exit(1) + + # Construct WebSocket URL + ws_url = f"wss://{args.host}/console?uuid={args.vm_uuid}&session_id={session_id}" + cookie = None + + else: + # Neither mode specified - show help + parser.print_help() + sys.exit(1) + + # If output directory is specified, prepend it to output_file + if args.output_dir: + os.makedirs(args.output_dir, exist_ok=True) + output_file = os.path.join(args.output_dir, output_file) + + try: + # Start WebSocket-to-TCP proxy (server is already serving) + local_port, server = await websocket_to_tcp_proxy(ws_url, cookie) + + try: + # Capture screenshot using VNC library with timeout + await asyncio.wait_for( + capture_vnc_screenshot(local_port, output_file), + timeout=30.0 + ) + print(f"Screenshot saved to {output_file}") + + finally: + # Clean up: close the server + logging.debug("Closing proxy server...") + server.close() + await server.wait_closed() + logging.debug("Proxy server closed") + + except asyncio.TimeoutError: + print("Error: Screenshot capture timed out after 30 seconds") + sys.exit(1) + except Exception as e: + print(f"Error: {e}") + if args.verbose: + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/uv.lock b/uv.lock index 75c6f68cd..7154123bb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [[package]] @@ -30,6 +30,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/b7/2ca5a126486a5323dde87cc43b207e926f3f3bce0b5758395308de3f146d/ansible_core-2.18.6-py3-none-any.whl", hash = "sha256:12a34749a7b20f0f1536bd3e3b2e137341867e4642e351273e96647161f595c0", size = 2208798, upload-time = "2025-05-19T16:59:57.372Z" }, ] +[[package]] +name = "asyncvnc2" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "keysymdef" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/35/d8763136f1bf597d8c0994d3da0e1f11cd099bce64ab67e6ec921705365a/asyncvnc2-2.0.0.tar.gz", hash = "sha256:a16d9061dfbedaecf4e42a9925a403a6b1ab47e9c32d0e200be23aadce3a2dbd", size = 25785, upload-time = "2025-03-18T17:23:24.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/28/ac3e36a8676fa8a2b441b279ac3e841964843e6b197c92095619b61cdb43/asyncvnc2-2.0.0-py3-none-any.whl", hash = "sha256:a6a996f14ae7836eb16a9c877b2884ababac68469bed3a5eaf418f5dd66f6928", size = 22368, upload-time = "2025-03-18T17:23:23.059Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.13.4" @@ -286,6 +300,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "keysymdef" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d3/c3db0b92a0ff39c3e08f168cd382c24bf021d4a96fc89b47a3e55294f883/keysymdef-1.2.0-py2.py3-none-any.whl", hash = "sha256:19a5c2263a861f3ff884a1f58e2b4f7efa319ffc9d11f9ba8e20129babc31a9e", size = 20146, upload-time = "2023-02-25T00:22:36.318Z" }, +] + [[package]] name = "legacycrypt" version = "0.3" @@ -402,6 +424,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "numpy" +version = "2.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload-time = "2025-10-15T16:15:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload-time = "2025-10-15T16:15:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload-time = "2025-10-15T16:15:25.572Z" }, + { url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload-time = "2025-10-15T16:15:27.079Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d2/137c7b6841c942124eae921279e5c41b1c34bab0e6fc60c7348e69afd165/numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7", size = 14591904, upload-time = "2025-10-15T16:15:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/bb/32/67e3b0f07b0aba57a078c4ab777a9e8e6bc62f24fb53a2337f75f9691699/numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda", size = 16939602, upload-time = "2025-10-15T16:15:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/95/22/9639c30e32c93c4cee3ccdb4b09c2d0fbff4dcd06d36b357da06146530fb/numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0", size = 16372661, upload-time = "2025-10-15T16:15:33.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/a685079529be2b0156ae0c11b13d6be647743095bb51d46589e95be88086/numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a", size = 18884682, upload-time = "2025-10-15T16:15:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/cf/85/f6f00d019b0cc741e64b4e00ce865a57b6bed945d1bbeb1ccadbc647959b/numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1", size = 6570076, upload-time = "2025-10-15T16:15:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/7d/10/f8850982021cb90e2ec31990291f9e830ce7d94eef432b15066e7cbe0bec/numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996", size = 13089358, upload-time = "2025-10-15T16:15:40.404Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ad/afdd8351385edf0b3445f9e24210a9c3971ef4de8fd85155462fc4321d79/numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c", size = 10462292, upload-time = "2025-10-15T16:15:42.896Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload-time = "2025-10-15T16:15:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload-time = "2025-10-15T16:15:47.761Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload-time = "2025-10-15T16:15:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload-time = "2025-10-15T16:15:52.442Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload-time = "2025-10-15T16:15:54.351Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload-time = "2025-10-15T16:15:56.67Z" }, + { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload-time = "2025-10-15T16:15:59.412Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload-time = "2025-10-15T16:16:01.804Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload-time = "2025-10-15T16:16:03.938Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload-time = "2025-10-15T16:16:05.801Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload-time = "2025-10-15T16:16:07.854Z" }, + { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" }, + { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" }, + { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" }, + { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" }, + { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" }, + { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" }, + { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" }, + { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" }, + { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" }, + { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" }, + { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" }, + { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" }, + { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" }, + { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload-time = "2025-10-15T16:17:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload-time = "2025-10-15T16:17:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload-time = "2025-10-15T16:18:00.596Z" }, + { url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload-time = "2025-10-15T16:18:02.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8c/cd283b54c3c2b77e188f63e23039844f56b23bba1712318288c13fe86baf/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847", size = 14422300, upload-time = "2025-10-15T16:18:04.271Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f0/8404db5098d92446b3e3695cf41c6f0ecb703d701cb0b7566ee2177f2eee/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d", size = 16760806, upload-time = "2025-10-15T16:18:06.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/2844c3959ce9a63acc7c8e50881133d86666f0420bcde695e115ced0920f/numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f", size = 12973130, upload-time = "2025-10-15T16:18:09.397Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -420,6 +523,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, + { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, + { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -666,19 +856,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + [[package]] name = "xcp-ng-tests" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "asyncvnc2" }, { name = "cryptography" }, { name = "gitpython" }, { name = "legacycrypt" }, { name = "packaging" }, + { name = "pillow" }, { name = "pluggy" }, { name = "pytest" }, { name = "pytest-dependency" }, { name = "requests" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -698,14 +933,17 @@ dev = [ [package.metadata] requires-dist = [ + { name = "asyncvnc2" }, { name = "cryptography", specifier = ">=3.3.1" }, { name = "gitpython" }, { name = "legacycrypt" }, { name = "packaging", specifier = ">=20.7" }, + { name = "pillow" }, { name = "pluggy", specifier = ">=1.1.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-dependency" }, { name = "requests" }, + { name = "websockets" }, ] [package.metadata.requires-dev] From c46efaea6338acdfbe9f3cc757131f8a6eac7e42 Mon Sep 17 00:00:00 2001 From: Emmanuel Varagnat Date: Thu, 6 Nov 2025 10:21:34 +0100 Subject: [PATCH 6/9] tests: ask for capture of windows guest tools on failure Signed-off-by: Emmanuel Varagnat --- tests/guest_tools/win/test_guest_tools_win.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/guest_tools/win/test_guest_tools_win.py b/tests/guest_tools/win/test_guest_tools_win.py index 31de0fc05..7b766d165 100644 --- a/tests/guest_tools/win/test_guest_tools_win.py +++ b/tests/guest_tools/win/test_guest_tools_win.py @@ -52,6 +52,7 @@ # └───install-drivers.ps1 +@pytest.mark.capture_console @pytest.mark.multi_vms @pytest.mark.usefixtures("windows_vm") class TestGuestToolsWindows: @@ -64,6 +65,7 @@ def test_drivers_detected(self, vm_install_test_tools_per_test_class): assert vm.are_windows_tools_working() +@pytest.mark.capture_console @pytest.mark.multi_vms @pytest.mark.usefixtures("windows_vm") class TestGuestToolsWindowsDestructive: From 51684370736437664e2ebcbe0b8c1f8565deb5a8 Mon Sep 17 00:00:00 2001 From: Emmanuel Varagnat Date: Wed, 5 Nov 2025 14:59:03 +0100 Subject: [PATCH 7/9] tests: test log collection on failure Signed-off-by: Emmanuel Varagnat --- tests/misc/test_log_collection.py | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/misc/test_log_collection.py diff --git a/tests/misc/test_log_collection.py b/tests/misc/test_log_collection.py new file mode 100644 index 000000000..659e9efdb --- /dev/null +++ b/tests/misc/test_log_collection.py @@ -0,0 +1,62 @@ +import pytest + +import logging + +from lib.host import Host +from lib.vm import VM + +# This test file demonstrates the automatic log collection mechanism +# When tests fail, logs will be automatically collected from the hosts involved +# Console screenshots are captured only for tests marked with @pytest.mark.capture_console + + +class TestLogCollection: + """Test class to verify automatic log collection on failure (no console capture).""" + + def test_passing_test(self, host: Host): + """This test passes, so no logs should be collected for it.""" + logging.info(f"Testing on host: {host.hostname_or_ip}") + result = host.ssh("echo 'Hello from passing test'") + assert "Hello" in result + + def test_failing_test(self, host: Host): + """This test fails intentionally to trigger log collection (but no console).""" + logging.info(f"Testing on host: {host.hostname_or_ip}") + # This assertion will fail, triggering log collection (but no console capture) + assert False, "Intentional failure to test log collection mechanism" + + @pytest.mark.skip(reason="Example of skipped test - no logs collected") + def test_skipped_test(self, host: Host): + """Skipped tests don't trigger log collection.""" + assert False + + # Its sole purpose is to test collection on multiple failures. + # Log collection is supposed to happen at the end of session. + def test_another_failure(self, host: Host): + """Another failing test.""" + logging.info("This test will also fail") + host.ssh("cat /nonexistent/file", check=False) + # Force a failure + assert False, "Second intentional failure" + + def test_yet_another_pass(self, host: Host): + """Another passing test.""" + assert host.ssh("hostname") + +@pytest.mark.capture_console +class TestWithConsoleCapture: + """Tests in this class will capture VM console screenshots on failure.""" + + def test_vm_failure_with_console(self, running_vm: VM): + """This test fails with a running VM - console will be captured.""" + vm = running_vm + logging.info(f"Testing VM {vm.uuid} on host {vm.host.hostname_or_ip}") + logging.info(f"VM power state: {vm.power_state()}") + # This will fail and trigger both log collection AND console capture + assert False, "Intentional failure to test console capture" + + def test_vm_passing_no_console(self, running_vm: VM): + """This test passes - no console capture needed.""" + vm = running_vm + logging.info(f"VM {vm.uuid} is running") + assert vm.is_running() From 7273e22c0a258aabeb13a7580804853dbdfbf562 Mon Sep 17 00:00:00 2001 From: Emmanuel Varagnat Date: Wed, 5 Nov 2025 17:04:14 +0100 Subject: [PATCH 8/9] scripts: add log extraction script for investigation Extract from syslog type logs based on epoch timestamps. Signed-off-by: Emmanuel Varagnat --- scripts/README.md | 24 +++++ scripts/extract-log.py | 229 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100755 scripts/extract-log.py diff --git a/scripts/README.md b/scripts/README.md index 3a7a404fa..6377cfb28 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -62,6 +62,30 @@ Build number assigned by Jenkins CI will be used to name the default directory, # extract_logs.py +extract_logs scripts is able to extract logs from syslogs type logs between 2 epochs. +The script expect the "basename" and is able to search through rotated and compressed files if found. + +Exemple of use: + +``` +$ cd / +$ head -n6 10.1.30.1_pytest-timestamps.log +begin tests.misc.test_basic_without_ssh 1762249737 +end tests.misc.test_basic_without_ssh 1762249802 +begin tests.misc.test_log_collection 1762262235 +end tests.misc.test_log_collection 1762262236 +begin tests.misc.test_log_collection 1762262824 +end tests.misc.test_log_collection 1762262826 + +# 1762262824 and 1762262826 are the timestamps we are interested in: + +$ tar xf 10.1.30.1_bug-report-20251110113603.tar.bz2 +$ cd bug-report-20251110113603 +$ ~/src/xcp-ng-tests/scripts/extract-log.py var/log/xensource.log 1762262824 1762262826 +... + +``` + # install_xcpng.py # get_xva_bridge.sh / set_xva_bridge.sh diff --git a/scripts/extract-log.py b/scripts/extract-log.py new file mode 100755 index 000000000..9c9636440 --- /dev/null +++ b/scripts/extract-log.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +Extract log entries from XenServer/XCP-ng log files within a specific time range. + +Usage: + extract-log.py /var/log/xensource.log 1761920132 1761920200 + extract-log.py var/log/xensource.log 1730720532 1730720600 > relevant_logs.txt + +Where the timestamps are epoch times (seconds since 1970-01-01 00:00:00 UTC). +You can get epoch time with: date '+%s' +Or for a specific date: date -d 'Oct 31 15:44:00' '+%s' + +This script: +- Handles log rotation (xensource.log, xensource.log.1, xensource.log.2.gz, etc.) +- Parses XenServer log format: "Oct 31 15:44:23 [log content]" +- Extracts only lines within the specified time range +- Maintains chronological order +- Supports gzip-compressed log files +""" + +import sys +import os +import glob +import gzip +from datetime import datetime +from typing import List, Tuple, Optional +import re + + +def parse_log_timestamp(month: str, day: str, time_str: str) -> Optional[int]: + """ + Parse XenServer log timestamp to epoch time. + + Args: + month: Month abbreviation (e.g., "Oct") + day: Day of month (e.g., "31") + time_str: Time string (e.g., "15:44:23") + + Returns: + Epoch timestamp in seconds, or None if parsing fails + """ + try: + # XenServer logs don't include year, so we need to infer it + # Assume current year, but handle year boundaries + current_year = datetime.now().year + + # Build date string with current year + date_str = f"{month} {day} {time_str} {current_year}" + dt = datetime.strptime(date_str, "%b %d %H:%M:%S %Y") + + # If the log timestamp is in the future, it's probably from last year + if dt.timestamp() > datetime.now().timestamp(): + date_str = f"{month} {day} {time_str} {current_year - 1}" + dt = datetime.strptime(date_str, "%b %d %H:%M:%S %Y") + + return int(dt.timestamp()) + except (ValueError, AttributeError): + return None + + +def open_log_file(filepath: str): + """ + Open a log file, handling both plain text and gzip compression. + + Args: + filepath: Path to the log file + + Returns: + File handle that can be iterated line by line + """ + if filepath.endswith('.gz'): + return gzip.open(filepath, 'rt', encoding='utf-8', errors='replace') + else: + return open(filepath, 'r', encoding='utf-8', errors='replace') + + +def extract_logs_from_file(filepath: str, min_time: int, max_time: int) -> List[str]: + """ + Extract log lines from a single file within the time range. + + Args: + filepath: Path to the log file + min_time: Minimum epoch timestamp (inclusive) + max_time: Maximum epoch timestamp (exclusive) + + Returns: + List of log lines within the time range + """ + lines = [] + + try: + with open_log_file(filepath) as f: + for line in f: + line = line.rstrip('\n') + if not line: + continue + + # Parse log line: "Oct 31 15:44:23 [rest of log]" + # XenServer log format: Month Day Time [content] + parts = line.split(None, 3) # Split on whitespace, max 4 parts + + if len(parts) >= 3: + month, day, time_str = parts[0], parts[1], parts[2] + + # Try to parse timestamp + timestamp = parse_log_timestamp(month, day, time_str) + + if timestamp is not None: + # Check if within range + if timestamp < min_time: + # Before range - skip for efficiency + continue + elif min_time <= timestamp < max_time: + # Within range - include + lines.append(line) + else: + # After range - stop reading this file + break + else: + # Line doesn't match expected format, might be continuation + # Include it if we're currently within range + if lines: # If we've already found lines in range + lines.append(line) + + except Exception as e: + print(f"Warning: Could not read {filepath}: {e}", file=sys.stderr) + + return lines + + +def natural_sort_key(filepath: str) -> List: + """ + Generate sort key for natural sorting of log files. + Handles: xensource.log, xensource.log.1, xensource.log.10, xensource.log.1.gz + + Args: + filepath: Path to the log file + + Returns: + Sort key for natural ordering + """ + def convert(text): + return int(text) if text.isdigit() else text.lower() + + # Remove .gz extension for sorting purposes + path = filepath.replace('.gz', '') + + # Split path into alphabetic and numeric parts + parts = re.split(r'(\d+)', path) + return [convert(part) for part in parts] + + +def extract_logs(base_log_path: str, min_time: int, max_time: int) -> List[str]: + """ + Extract logs from all rotated log files within the time range. + + Args: + base_log_path: Base path to the log file (e.g., /var/log/xensource.log) + min_time: Minimum epoch timestamp (inclusive) + max_time: Maximum epoch timestamp (exclusive) + + Returns: + List of log lines within the time range, in chronological order + """ + # Find all related log files (including rotated and compressed) + log_pattern = f"{base_log_path}*" + log_files = glob.glob(log_pattern) + + if not log_files: + print(f"Error: No log files found matching {log_pattern}", file=sys.stderr) + return [] + + # Sort log files naturally (oldest to newest) + # xensource.log is current, xensource.log.1 is previous, etc. + log_files.sort(key=natural_sort_key) + + all_lines = [] + + # Process each log file + for log_file in log_files: + lines = extract_logs_from_file(log_file, min_time, max_time) + all_lines.extend(lines) + + return all_lines + + +def main(): + """Main entry point for the script.""" + if len(sys.argv) != 4: + print("Usage: extract-log.py ", file=sys.stderr) + print("", file=sys.stderr) + print("Example:", file=sys.stderr) + print(" extract-log.py /var/log/xensource.log 1761920132 1761920200", file=sys.stderr) + print(" extract-log.py var/log/xensource.log 1730720532 1730720600", file=sys.stderr) + print("", file=sys.stderr) + print("Get current epoch time: date '+%s'", file=sys.stderr) + print("Get epoch for specific date: date -d 'Oct 31 15:44:00' '+%s'", file=sys.stderr) + sys.exit(1) + + base_log_path = sys.argv[1] + + try: + min_time = int(sys.argv[2]) + max_time = int(sys.argv[3]) + except ValueError: + print("Error: start_epoch and end_epoch must be integers", file=sys.stderr) + sys.exit(1) + + if min_time >= max_time: + print("Error: start_epoch must be less than end_epoch", file=sys.stderr) + sys.exit(1) + + # Extract and print logs + lines = extract_logs(base_log_path, min_time, max_time) + + if not lines: + print(f"No log entries found in time range {min_time} to {max_time}", file=sys.stderr) + sys.exit(0) + + # Print all extracted lines + for line in lines: + print(line) + + # Print summary to stderr + print(f"\n# Extracted {len(lines)} log lines from {min_time} to {max_time}", file=sys.stderr) + + +if __name__ == "__main__": + main() From 8ebf7af7d305540fbd266d652ed63ac84086f7f6 Mon Sep 17 00:00:00 2001 From: Emmanuel Varagnat Date: Fri, 7 Nov 2025 15:55:01 +0100 Subject: [PATCH 9/9] Register new 'no_auto' job for special purpose tests Test for log collection need to fail in order to trigger the log collection; so pytest.mark.xfail can't be used. But tests need to by referenced by a job, otherwise './jobs.py check' will fail. Signed-off-by: Emmanuel Varagnat --- jobs.py | 9 +++++++++ tests/misc/test_log_collection.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/jobs.py b/jobs.py index 2c9f16405..5f60fa52e 100755 --- a/jobs.py +++ b/jobs.py @@ -446,6 +446,15 @@ "nb_pools": 2, "params": {}, "paths": ["tests/misc/test_pool.py"], + }, + "no_auto": { + "description": "Tests for special purposes", + "requirements": [ + "1 XCP-ng host >= 8.2" + ], + "nb_pools": 1, + "params": {}, + "paths": ["tests/misc/test_log_collection.py"], } } diff --git a/tests/misc/test_log_collection.py b/tests/misc/test_log_collection.py index 659e9efdb..17bc74e3e 100644 --- a/tests/misc/test_log_collection.py +++ b/tests/misc/test_log_collection.py @@ -47,6 +47,7 @@ def test_yet_another_pass(self, host: Host): class TestWithConsoleCapture: """Tests in this class will capture VM console screenshots on failure.""" + @pytest.mark.small_vm def test_vm_failure_with_console(self, running_vm: VM): """This test fails with a running VM - console will be captured.""" vm = running_vm @@ -55,6 +56,7 @@ def test_vm_failure_with_console(self, running_vm: VM): # This will fail and trigger both log collection AND console capture assert False, "Intentional failure to test console capture" + @pytest.mark.small_vm def test_vm_passing_no_console(self, running_vm: VM): """This test passes - no console capture needed.""" vm = running_vm