From 6a687b29e571fc03dd0f549ea46db7d226c32f4f Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Wed, 9 Sep 2026 16:13:49 +0200 Subject: [PATCH] test(driver-snmp): add protocol-level tests for SNMPv3 wire traffic Exercise the driver with real in-process UDP SNMPv3 traffic against a local agent instead of mocks only: - USM auth (MD5/SHA) and auth+priv (DES/AES) round-trips - SET varbind encoding for on/off, default and custom OID/plug - agent errorStatus propagation, no-response timeout, unresolvable host - full client round-trip through jumpstarter serve() (gRPC/UNIX socket) Also declare cryptography>=43.0.3 as a direct dependency: DES/AES privacy requires it at runtime and it was previously only pulled transitively via jumpstarter, missing from the isolated test env. --- .../driver_protocol_test.py | 319 ++++++++++++++++++ .../jumpstarter-driver-snmp/pyproject.toml | 1 + python/uv.lock | 54 +-- 3 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver_protocol_test.py diff --git a/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver_protocol_test.py b/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver_protocol_test.py new file mode 100644 index 000000000..58642b3d6 --- /dev/null +++ b/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver_protocol_test.py @@ -0,0 +1,319 @@ +"""Protocol-level (integration) tests for the SNMP power driver. + +These tests run a real in-process pysnmp SNMPv3 agent on a loopback UDP port +so the driver performs actual SNMPv3 SET traffic on the wire (USM +authentication, privacy, error-status propagation, and timeouts), instead of +mocking the pysnmp internals as in driver_test.py. +""" + +from __future__ import annotations + +import asyncio +import socket +import threading +import time +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any + +import pytest +from pysnmp.carrier.asyncio.dgram import udp +from pysnmp.entity import config, engine +from pysnmp.entity.rfc3413 import cmdrsp, context +from pysnmp.proto.api import v2c + +from jumpstarter_driver_snmp.driver import AuthProtocol, PrivProtocol, SNMPError, SNMPServer + +from jumpstarter.common.utils import serve + + +@pytest.fixture(autouse=True) +def _reset_event_loop(): + """The driver creates, sets and closes its own event loop per operation, + leaving a closed loop registered on the main thread. Replace it with a + fresh open loop after each test so tests running later (e.g. the mock + based driver_test.py) do not hit a closed loop via asyncio.get_event_loop().""" + yield + asyncio.set_event_loop(asyncio.new_event_loop()) + + +class _RecordingSetResponder(cmdrsp.SetCommandResponder): + """SET responder that records received varbinds and answers with a + configurable SNMP error status.""" + + def __init__(self, snmpEngine, snmpContext, agent: SnmpTestAgent): + super().__init__(snmpEngine, snmpContext) + self._agent = agent + + def handle_management_operation(self, snmpEngine, stateReference, contextName, PDU): + varBinds = v2c.apiPDU.get_varbinds(PDU) + self._agent.record([(tuple(int(component) for component in oid), int(value)) for oid, value in varBinds]) + self.send_varbinds( + snmpEngine, + stateReference, + self._agent.error_status, + self._agent.error_index, + varBinds, + ) + self.release_state_information(stateReference) + + +class SnmpTestAgent: + """In-process SNMPv3 agent serving SET requests on a loopback UDP port. + + Runs a dedicated asyncio event loop in a background thread so it can + answer the driver while the driver runs its own loop. + """ + + def __init__( + self, + user: str = "agent-user", + auth_protocol: Any = config.USM_AUTH_NONE, + auth_key: str | None = None, + priv_protocol: Any = config.USM_PRIV_NONE, + priv_key: str | None = None, + error_status: int = 0, + error_index: int = 0, + ): + self.user = user + self.auth_protocol = auth_protocol + self.auth_key = auth_key + self.priv_protocol = priv_protocol + self.priv_key = priv_key + self.error_status = error_status + self.error_index = error_index + self.port: int = 0 + self.requests: list[list[tuple[tuple[int, ...], int]]] = [] + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._engine: Any = None + self._responder: _RecordingSetResponder | None = None + self._ready = threading.Event() + self._error: BaseException | None = None + + def start(self) -> SnmpTestAgent: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + self.port = sock.getsockname()[1] + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._run, args=(sock,), daemon=True) + self._thread.start() + if not self._ready.wait(timeout=10): + raise RuntimeError("SNMP test agent did not start in time") + if self._error is not None: + raise self._error + return self + + def record(self, varbinds: list[tuple[tuple[int, ...], int]]): + self.requests.append(varbinds) + + def _run(self, sock: socket.socket): + asyncio.set_event_loop(self._loop) + try: + try: + self._loop.run_until_complete(self._setup(sock)) + except BaseException as e: + self._error = e + self._ready.set() + return + self._ready.set() + self._loop.run_forever() + finally: + self._loop.close() + + async def _setup(self, sock: socket.socket): + self._engine = engine.SnmpEngine() + config.add_v3_user( + self._engine, + self.user, + self.auth_protocol, + self.auth_key, + self.priv_protocol, + self.priv_key, + ) + snmp_context = context.SnmpContext(self._engine) + self._responder = _RecordingSetResponder(self._engine, snmp_context, self) + # On first contact the client puts its own engine id on the wire as + # contextEngineId. Register the responder against the empty + # contextEngineId too, so the dispatcher's wildcard fallback matches + # whatever engine id the client sends. + self._engine.message_dispatcher.register_context_engine_id( + b"", self._responder.SUPPORTED_PDU_TYPES, self._responder.process_pdu + ) + transport = udp.UdpAsyncioTransport() + transport.open_server_mode(sock=sock) + config.add_transport(self._engine, udp.DOMAIN_NAME, transport) + self._engine.open_dispatcher() + + def _shutdown(self): + try: + if self._responder is not None: + self._engine.message_dispatcher.unregister_context_engine_id(b"", self._responder.SUPPORTED_PDU_TYPES) + self._responder.close(self._engine) + if self._engine is not None: + self._engine.close_dispatcher() + finally: + self._loop.stop() + + def stop(self): + if self._thread is None: + return + if self._error is None: + self._loop.call_soon_threadsafe(self._shutdown) + self._thread.join(timeout=10) + if self._thread.is_alive(): + raise RuntimeError("SNMP test agent did not stop in time") + + def __enter__(self) -> SnmpTestAgent: + return self.start() + + def __exit__(self, *exc_info): + self.stop() + + +@contextmanager +def _blackhole_port() -> Generator[int, None, None]: + """Yield a UDP port that is bound but never answers.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + try: + yield sock.getsockname()[1] + finally: + sock.close() + + +async def test_on_sends_set_varbind_with_one(): + with SnmpTestAgent() as agent: + server = SNMPServer(host="127.0.0.1", port=agent.port, user="agent-user", plug=1) + result = await server.on() + + assert "Power ON" in result + assert agent.requests == [[((1, 3, 6, 1, 4, 1, 13742, 6, 4, 1, 2, 1, 2, 1, 1), 1)]] + + +async def test_off_sends_set_varbind_with_zero(): + with SnmpTestAgent() as agent: + server = SNMPServer(host="127.0.0.1", port=agent.port, user="agent-user", plug=1) + result = await server.off() + + assert "Power OFF" in result + assert agent.requests == [[((1, 3, 6, 1, 4, 1, 13742, 6, 4, 1, 2, 1, 2, 1, 1), 0)]] + + +async def test_custom_oid_and_plug_sent_on_wire(): + with SnmpTestAgent() as agent: + server = SNMPServer( + host="127.0.0.1", + port=agent.port, + user="agent-user", + plug=7, + oid="1.3.6.1.2.1.95", + ) + await server.off() + + assert agent.requests == [[((1, 3, 6, 1, 2, 1, 95, 7), 0)]] + + +@pytest.mark.parametrize( + ("driver_auth", "usm_auth"), + [ + (AuthProtocol.MD5, config.USM_AUTH_HMAC96_MD5), + (AuthProtocol.SHA, config.USM_AUTH_HMAC96_SHA), + ], +) +async def test_auth_no_priv(driver_auth, usm_auth): + with SnmpTestAgent(auth_protocol=usm_auth, auth_key="correct-key") as agent: + server = SNMPServer( + host="127.0.0.1", + port=agent.port, + user="agent-user", + plug=1, + auth_protocol=driver_auth, + auth_key="correct-key", + ) + await server.on() + + assert len(agent.requests) == 1 + + +async def test_wrong_auth_key_rejected(): + with SnmpTestAgent(auth_protocol=config.USM_AUTH_HMAC96_MD5, auth_key="correct-key") as agent: + server = SNMPServer( + host="127.0.0.1", + port=agent.port, + user="agent-user", + plug=1, + auth_protocol=AuthProtocol.MD5, + auth_key="wrong-key", + timeout=1.0, + ) + with pytest.raises(SNMPError): + await server.on() + + assert agent.requests == [] + + +@pytest.mark.parametrize( + ("driver_priv", "usm_priv"), + [ + (PrivProtocol.DES, config.USM_PRIV_CBC56_DES), + (PrivProtocol.AES, config.USM_PRIV_CFB128_AES), + ], +) +async def test_auth_priv(driver_priv, usm_priv): + with SnmpTestAgent( + auth_protocol=config.USM_AUTH_HMAC96_SHA, + auth_key="auth-key-123", + priv_protocol=usm_priv, + priv_key="priv-key-123", + ) as agent: + server = SNMPServer( + host="127.0.0.1", + port=agent.port, + user="agent-user", + plug=1, + auth_protocol=AuthProtocol.SHA, + auth_key="auth-key-123", + priv_protocol=driver_priv, + priv_key="priv-key-123", + ) + await server.on() + + assert len(agent.requests) == 1 + + +async def test_agent_error_status_propagates(): + # noSuchName (2) on the first varbind + with SnmpTestAgent(error_status=2, error_index=1) as agent: + server = SNMPServer(host="127.0.0.1", port=agent.port, user="agent-user", plug=1) + with pytest.raises(SNMPError, match="SNMP error: noSuchName at 1.3.6.1.4.1.13742"): + await server.on() + + assert len(agent.requests) == 1 + + +async def test_no_response_times_out(): + with _blackhole_port() as port: + server = SNMPServer(host="127.0.0.1", port=port, user="agent-user", plug=1, timeout=1.0) + start = time.monotonic() + with pytest.raises(SNMPError, match="timed out"): + await server.on() + elapsed = time.monotonic() - start + + assert elapsed >= 0.8 + + +def test_unresolvable_host_raises(): + with pytest.raises(SNMPError, match="Failed to resolve hostname"): + SNMPServer(host="jumpstarter.invalid", user="agent-user", plug=1) + + +def test_client_on_off_via_serve(): + with SnmpTestAgent() as agent: + server = SNMPServer(host="127.0.0.1", port=agent.port, user="agent-user", plug=3) + with serve(server) as client: + client.on() + client.off() + + values = [value for request in agent.requests for _oid, value in request] + assert values == [1, 0] diff --git a/python/packages/jumpstarter-driver-snmp/pyproject.toml b/python/packages/jumpstarter-driver-snmp/pyproject.toml index 0ce52593b..a4b7fd2b7 100644 --- a/python/packages/jumpstarter-driver-snmp/pyproject.toml +++ b/python/packages/jumpstarter-driver-snmp/pyproject.toml @@ -9,6 +9,7 @@ authors = [{ name = "Benny Zlotnik", email = "bzlotnik@redhat.com" }] dependencies = [ "click>=8.1.8", + "cryptography>=43.0.3", "jumpstarter", "jumpstarter-driver-power", "pysnmp==7.1.29", diff --git a/python/uv.lock b/python/uv.lock index 6cd018d16..a9d94d08c 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -3034,6 +3034,7 @@ name = "jumpstarter-driver-snmp" source = { editable = "packages/jumpstarter-driver-snmp" } dependencies = [ { name = "click" }, + { name = "cryptography" }, { name = "jumpstarter" }, { name = "jumpstarter-driver-power" }, { name = "pysnmp" }, @@ -3051,6 +3052,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.8" }, + { name = "cryptography", specifier = ">=43.0.3" }, { name = "jumpstarter", editable = "packages/jumpstarter" }, { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, { name = "pysnmp", specifier = "==7.1.29" }, @@ -3891,7 +3893,7 @@ name = "libusb-package" version = "1.0.26.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-resources" }, + { name = "importlib-resources", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/01/34/fd042950325366cb8f01e1873cd87f8aa99e773586377a8e670d6127dc8e/libusb_package-1.0.26.4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:ce3711a82a2b4b9936a6268c9cbad794c34ae3b1b2c5cc9aeced8340db678668", size = 63859, upload-time = "2026-06-19T11:04:23.822Z" }, @@ -4680,7 +4682,7 @@ name = "prompt-toolkit" version = "3.0.53" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth" }, + { name = "wcwidth", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } wheels = [ @@ -4816,10 +4818,10 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "python_full_version < '3.14'" }, + { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "typing-inspection", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload-time = "2025-10-04T10:40:41.338Z" } wheels = [ @@ -4836,10 +4838,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "python_full_version >= '3.14'" }, + { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -4856,7 +4858,7 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } wheels = [ @@ -4903,7 +4905,7 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -5103,7 +5105,7 @@ name = "pyobjc-framework-cocoa" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/6f/89837da349fe7de6476c426f118096b147de923139556d98af1832c64b97/pyobjc_framework_cocoa-12.0.tar.gz", hash = "sha256:02d69305b698015a20fcc8e1296e1528e413d8cf9fdcd590478d359386d76e8a", size = 2771906, upload-time = "2025-10-21T08:30:51.765Z" } wheels = [ @@ -5119,8 +5121,8 @@ name = "pyobjc-framework-corebluetooth" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/b2/ad9e8516cd73611a3a8f8ff2d7d51b917115f3f7f9e7a9760d5fc4e9dd6b/pyobjc_framework_corebluetooth-12.0.tar.gz", hash = "sha256:61ae2a56c3dcb8b7307d833e7d913bd7c063d11a1ea931158facceb38aae21d3", size = 33587, upload-time = "2025-10-21T08:31:18.036Z" } wheels = [ @@ -5136,8 +5138,8 @@ name = "pyobjc-framework-libdispatch" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/7e/251ea268ce5a341586c963de758c7ff6dea681c98a1fb6da87f6d0004bd3/pyobjc_framework_libdispatch-12.0.tar.gz", hash = "sha256:2ef31c02670c377d9e2875e74053087b1d96b240d2fc8721cc4c665c05394b3a", size = 38599, upload-time = "2025-10-21T08:34:38.878Z" } wheels = [ @@ -6692,8 +6694,8 @@ name = "winrt-runtime" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } wheels = [ @@ -6713,7 +6715,7 @@ name = "winrt-windows-devices-bluetooth" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } wheels = [ @@ -6733,7 +6735,7 @@ name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } wheels = [ @@ -6753,7 +6755,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } wheels = [ @@ -6773,7 +6775,7 @@ name = "winrt-windows-devices-enumeration" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } wheels = [ @@ -6793,7 +6795,7 @@ name = "winrt-windows-foundation" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } wheels = [ @@ -6813,7 +6815,7 @@ name = "winrt-windows-foundation-collections" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } wheels = [ @@ -6833,7 +6835,7 @@ name = "winrt-windows-storage-streams" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } wheels = [