From 69b8c69f3577e78486ed1651f3328c1d7146f24e Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 23 May 2024 09:01:52 -0500 Subject: [PATCH 01/11] backends/winrt: don't throw exeception for properly configued GUI apps In commit 4a653e6 ("backends/winrt: raise exception when trying to scan with STA") we added a check to raise an exception when trying to scan when PyWinRT set the aparatment model to STA. However, properly working GUI apps will have the apartment model set to STA but Bleak will still work because there is something pumping the Windows message loop. We don't want to raise an exception in this case to avoid breaking working apps. We can improve the test by checking if the current thread is actually pumping the message loop by scheduling a callback via a the win32 SetTimeout function. If the callback is called, then we know that the message loop is being pumped. If not, then we probably are not going to get async callbacks from the WinRT APIs and we raise an exception in this case. --- CHANGELOG.rst | 1 + bleak/backends/winrt/scanner.py | 2 +- bleak/backends/winrt/util.py | 79 ++++++++++++++++++++++++++++++--- docs/troubleshooting.rst | 4 +- 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fb25f57f..3050b0e7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,7 @@ Fixed * Fixed ``discovered_devices_and_advertisement_data`` returning devices that should be filtered out by service UUIDs. Fixes #1576. * Fixed a ``Descriptor None was not found!`` exception occurring in ``start_notify()`` on Android. Fixes #823. +* Fixed exception raised when starting ``BleakScanner`` while running in a Windows GUI app. `0.22.1`_ (2024-05-07) ====================== diff --git a/bleak/backends/winrt/scanner.py b/bleak/backends/winrt/scanner.py index 13bfe0d3..723ae1fe 100644 --- a/bleak/backends/winrt/scanner.py +++ b/bleak/backends/winrt/scanner.py @@ -222,7 +222,7 @@ async def start(self) -> None: # Callbacks for WinRT async methods will never happen in STA mode if # there is nothing pumping a Windows message loop. - assert_mta() + await assert_mta() # start with fresh list of discovered devices self.seen_devices = {} diff --git a/bleak/backends/winrt/util.py b/bleak/backends/winrt/util.py index 50fac389..4f1a0a8b 100644 --- a/bleak/backends/winrt/util.py +++ b/bleak/backends/winrt/util.py @@ -1,10 +1,19 @@ +import asyncio import ctypes +from ctypes import wintypes from enum import IntEnum from typing import Tuple from ...exc import BleakError +def _check_result(result, func, args): + if not result: + raise ctypes.WinError() + + return args + + def _check_hresult(result, func, args): if result: raise ctypes.WinError(result) @@ -12,6 +21,29 @@ def _check_hresult(result, func, args): return args +# not defined in wintypes +if ctypes.sizeof(ctypes.c_long) == ctypes.sizeof(ctypes.c_void_p): + _UINT_PTR = ctypes.c_ulong +elif ctypes.sizeof(ctypes.c_longlong) == ctypes.sizeof(ctypes.c_void_p): + _UINT_PTR = ctypes.c_ulonglong + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-timerproc +_TIMERPROC = ctypes.WINFUNCTYPE( + None, wintypes.HWND, _UINT_PTR, wintypes.UINT, wintypes.DWORD +) + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-settimer +_SetTimer = ctypes.windll.user32.SetTimer +_SetTimer.restype = _UINT_PTR +_SetTimer.argtypes = [wintypes.HWND, _UINT_PTR, wintypes.UINT, _TIMERPROC] +_SetTimer.errcheck = _check_result + +# https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-killtimer +_KillTimer = ctypes.windll.user32.KillTimer +_KillTimer.restype = wintypes.BOOL +_KillTimer.argtypes = [wintypes.HWND, wintypes.UINT] + + # https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-cogetapartmenttype _CoGetApartmentType = ctypes.windll.ole32.CoGetApartmentType _CoGetApartmentType.restype = ctypes.c_int @@ -60,7 +92,7 @@ def _get_apartment_type() -> Tuple[_AptType, _AptQualifierType]: return _AptType(api_type.value), _AptQualifierType(api_type_qualifier.value) -def assert_mta() -> None: +async def assert_mta() -> None: """ Asserts that the current apartment type is MTA. @@ -68,20 +100,53 @@ def assert_mta() -> None: BleakError: If the current apartment type is not MTA. .. versionadded:: 0.22 + + .. versionchanged:: unreleased """ if hasattr(allow_sta, "_allowed"): return try: apt_type, _ = _get_apartment_type() - if apt_type != _AptType.MTA: - raise BleakError( - f"The current thread apartment type is not MTA: {apt_type.name}. Beware of packages like pywin32 that may change the apartment type implicitly." - ) except OSError as e: # All is OK if not initialized yet. WinRT will initialize it. - if e.winerror != _CO_E_NOTINITIALIZED: - raise + if e.winerror == _CO_E_NOTINITIALIZED: + return + + raise + + if apt_type == _AptType.MTA: + # if we get here, WinRT probably set the apartment type to MTA and all + # is well, we don't need to check again + setattr(assert_mta, "_allowed", True) + return + + event = asyncio.Event() + + def wait_event(*_): + event.set() + + # have to keep a reference to the callback or it will be garbage collected + # before it is called + callback = _TIMERPROC(wait_event) + + # set a timer to see if we get a callback to ensure the windows event loop + # is running + timer = _SetTimer(None, 1, 0, callback) + + try: + async with asyncio.timeout(1): + await event.wait() + except asyncio.TimeoutError: + raise BleakError( + "Thread is configured for Windows GUI but callbacks are not working. Suspect PyWin32 unwanted side effects." + ) + else: + # if the windows event loop is running, we assume it is going to keep + # running and we don't need to check again + setattr(assert_mta, "_allowed", True) + finally: + _KillTimer(None, timer) def allow_sta(): diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 7e5c3cfc..7cc88cdf 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -183,7 +183,7 @@ isn't a message loop running. Bleak needs to run in a Multi Threaded Apartment Bleak should detect this and raise an exception with a message similar to:: - The current thread apartment type is not MTA: STA. + Thread is configured for Windows GUI but callbacks are not working. To work around this, you can use one of the utility functions provided by Bleak. @@ -202,7 +202,7 @@ thread then call ``allow_sta()`` before calling any other Bleak APis:: pass The more typical case, though, is that some library has imported something like -``pywin32`` which breaks Bleak. In this case, you can uninitialize the threading +``win32com`` which breaks Bleak. In this case, you can uninitialize the threading model like this:: import win32com # this sets current thread to STA :-( From 45cab2da5878477ddf92f4e93ef4b7369a6e695c Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 08:44:22 -0500 Subject: [PATCH 02/11] fix async timeout on older Python --- bleak/backends/winrt/util.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bleak/backends/winrt/util.py b/bleak/backends/winrt/util.py index 4f1a0a8b..a127c903 100644 --- a/bleak/backends/winrt/util.py +++ b/bleak/backends/winrt/util.py @@ -1,11 +1,17 @@ import asyncio import ctypes +import sys from ctypes import wintypes from enum import IntEnum from typing import Tuple from ...exc import BleakError +if sys.version_info < (3, 11): + from async_timeout import timeout as async_timeout +else: + from asyncio import timeout as async_timeout + def _check_result(result, func, args): if not result: @@ -135,7 +141,7 @@ def wait_event(*_): timer = _SetTimer(None, 1, 0, callback) try: - async with asyncio.timeout(1): + async with async_timeout(1): await event.wait() except asyncio.TimeoutError: raise BleakError( From 59a4fbb5c944710631a3de5108ccab4e88cdbbc1 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 17:03:27 -0500 Subject: [PATCH 03/11] add some tests --- poetry.lock | 89 +++++++++--------------- pyproject.toml | 4 +- tests/bleak/backends/winrt/test_utils.py | 66 ++++++++++++++++++ 3 files changed, 102 insertions(+), 57 deletions(-) create mode 100644 tests/bleak/backends/winrt/test_utils.py diff --git a/poetry.lock b/poetry.lock index 1baa7f78..71369f90 100644 --- a/poetry.lock +++ b/poetry.lock @@ -22,23 +22,6 @@ files = [ {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, ] -[[package]] -name = "attrs" -version = "22.1.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.5" -files = [ - {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, - {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, -] - -[package.extras] -dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] -docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] -tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] -tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] - [[package]] name = "Babel" version = "2.10.3" @@ -288,6 +271,20 @@ files = [ {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, ] +[[package]] +name = "exceptiongroup" +version = "1.2.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, + {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, +] + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "flake8" version = "5.0.4" @@ -497,30 +494,19 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock [[package]] name = "pluggy" -version = "1.0.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] - [[package]] name = "pycodestyle" version = "2.9.1" @@ -630,43 +616,43 @@ pyobjc-core = ">=10.0" [[package]] name = "pytest" -version = "7.1.3" +version = "8.2.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, - {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, + {file = "pytest-8.2.1-py3-none-any.whl", hash = "sha256:faccc5d332b8c3719f40283d0d44aa5cf101cec36f88cde9ed8f2bc0538612b1"}, + {file = "pytest-8.2.1.tar.gz", hash = "sha256:5046e5b46d8e4cac199c373041f26be56fdb81eb4e67dc11d4e10811fc3408fd"}, ] [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -tomli = ">=1.0.0" +pluggy = ">=1.5,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.19.0" +version = "0.23.7" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.19.0.tar.gz", hash = "sha256:ac4ebf3b6207259750bc32f4c1d8fcd7e79739edbc67ad0c58dd150b1d072fed"}, - {file = "pytest_asyncio-0.19.0-py3-none-any.whl", hash = "sha256:7a97e37cfe1ed296e2e84941384bdd37c376453912d397ed39293e0916f521fa"}, + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, ] [package.dependencies] -pytest = ">=6.1.0" +pytest = ">=7.0.0,<9" [package.extras] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" @@ -950,7 +936,6 @@ files = [ {file = "winrt_Windows.Devices.Bluetooth-2.0.1-cp39-cp39-win32.whl", hash = "sha256:dffff7e6801b8e69e694b36fe1d147094fb6ac29ce54fd3ca3e52ab417473cc4"}, {file = "winrt_Windows.Devices.Bluetooth-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:62bae806ecdf3021e1ec685d5a44012657c0961ca2027eeb1c37864f53577e51"}, {file = "winrt_Windows.Devices.Bluetooth-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:7f3b102e9b4bea1915cc922b571e0c226956c161102d228ec1788e3caf4e226d"}, - {file = "winrt_windows_devices_bluetooth-2.0.1.tar.gz", hash = "sha256:c91b3f54bfe1ed7e1e597566b83a625d32efe397b21473668046ccb4b57f5a28"}, ] [package.dependencies] @@ -978,7 +963,6 @@ files = [ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.1-cp39-cp39-win32.whl", hash = "sha256:86d11fd5c055f76eefac7f6cc02450832811503b83280e26a83613afe1d17c92"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:c8495ce12fda8fce3da130664917eb199d19ca1ebf7d5ab996f5df584b5e3a1f"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:0e91160a98e5b0fffae196982b5670e678ac919a6e14eb7e9798fdcbff45f8d2"}, - {file = "winrt_windows_devices_bluetooth_advertisement-2.0.1.tar.gz", hash = "sha256:130e6238a1897bfef98a711cdb1b02694fa0e18eb67d8fd4019a64a53685b331"}, ] [package.dependencies] @@ -1006,7 +990,6 @@ files = [ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.1-cp39-cp39-win32.whl", hash = "sha256:3e2a54db384dcf05265a855a2548e2abd9b7726c8ec4b9ad06059606c5d90409"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:2bdbb55d4bef15c762a5d5b4e27b534146ec6580075ed9cc681e75e6ff0d5a97"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:01e74c76d4f16b4490d78c8c7509f2570c843366c1c6bf196a5b729520a31258"}, - {file = "winrt_windows_devices_bluetooth_genericattributeprofile-2.0.1.tar.gz", hash = "sha256:69d7dabd53fbf9acdc2d206def60f5c9777416a9d6911c3420be700aaff4e492"}, ] [package.dependencies] @@ -1034,7 +1017,6 @@ files = [ {file = "winrt_Windows.Devices.Enumeration-2.0.1-cp39-cp39-win32.whl", hash = "sha256:9301f5e00bd2562b063e0f6e0de6f0596b7fb3eabc443bd7e115772de6cc08f9"}, {file = "winrt_Windows.Devices.Enumeration-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:9999d93ae9441d35c564d498bb4d6767b593254a92b7c1559058a7450a0c304e"}, {file = "winrt_Windows.Devices.Enumeration-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:504ca45a9b90387a2f4f727dbbeefcf79beb013ac7a29081bb14c8ab13e10367"}, - {file = "winrt_windows_devices_enumeration-2.0.1.tar.gz", hash = "sha256:ed227dd22ece253db913de24e4fc5194d9f3272e2a5959a2450ae79e81bf7949"}, ] [package.dependencies] @@ -1062,7 +1044,6 @@ files = [ {file = "winrt_Windows.Foundation-2.0.1-cp39-cp39-win32.whl", hash = "sha256:7abbf10666d6da5dbfb6a47125786a05dac267731a3d38feb8faddade9bf1151"}, {file = "winrt_Windows.Foundation-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:aab18ad12de63a353ab1847aff3216ba4e5499e328da5edcb72c8007da6bdb02"}, {file = "winrt_Windows.Foundation-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:bde9ecfc1c75410d669ee3124a84ba101d5a8ab1911807ad227658624fc22ffb"}, - {file = "winrt_windows_foundation-2.0.1.tar.gz", hash = "sha256:6e4da10cff652ac17740753c38ebe69565f5f970f60100106469b2e004ef312c"}, ] [package.dependencies] @@ -1090,7 +1071,6 @@ files = [ {file = "winrt_Windows.Foundation.Collections-2.0.1-cp39-cp39-win32.whl", hash = "sha256:c26ab7b3342669dc09be62db5c5434e7194fb6eb1ec5b03fba1163f6b3e7b843"}, {file = "winrt_Windows.Foundation.Collections-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:2f9bc7e28f3ade1c1f3113939dbf630bfef5e3c3018c039a404d7e4d39aae4cb"}, {file = "winrt_Windows.Foundation.Collections-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:1f3e76f3298bec3938d94e4857c29af9776ec78112bdd09bb7794f06fd38bb13"}, - {file = "winrt_windows_foundation_collections-2.0.1.tar.gz", hash = "sha256:7d18955f161ba27d785c8fe2ef340f338b6edd2c5226fe2b005840e2a855e708"}, ] [package.dependencies] @@ -1118,7 +1098,6 @@ files = [ {file = "winrt_Windows.Storage.Streams-2.0.1-cp39-cp39-win32.whl", hash = "sha256:f6dec418ad0118c258a1b2999fc8d4fc0d9575e6353a75a242ff8cc63c9b2146"}, {file = "winrt_Windows.Storage.Streams-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:9fbc40f600ab44a45cda47b698bd8e494e80e221446a5958c4d8d59a8d46f117"}, {file = "winrt_Windows.Storage.Streams-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:08059774c6d49d195ce00c3802d19364f418a6f3e42b94373621551792d2da60"}, - {file = "winrt_windows_storage_streams-2.0.1.tar.gz", hash = "sha256:3de8351ed3a9cfcfd1d028ce97ffe90bb95744f906eef025b06e7f4431943ee6"}, ] [package.dependencies] @@ -1145,4 +1124,4 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.13" -content-hash = "65f1bbdea293cf8fef2b3531e7f28b9aff5b62df6c9bbf4a290d9cdfe5f8884d" +content-hash = "1c5a0ca2a13af74c3aecab397053176e19054a14bcbb031d89c8413acaf8a468" diff --git a/pyproject.toml b/pyproject.toml index cf027c0e..c4e87525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,8 +49,8 @@ flake8 = "^5.0.0" isort = "^5.13.2" [tool.poetry.group.test.dependencies] -pytest = "^7.0.0" -pytest-asyncio = "^0.19.0" +pytest = "^8.2.1" +pytest-asyncio = "^0.23.7" pytest-cov = "^3.0.0 " [build-system] diff --git a/tests/bleak/backends/winrt/test_utils.py b/tests/bleak/backends/winrt/test_utils.py new file mode 100644 index 00000000..853a6e25 --- /dev/null +++ b/tests/bleak/backends/winrt/test_utils.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python + +"""Tests for `bleak.backends.winrt.util` package.""" + +import sys + +import pytest + +if not sys.platform.startswith("win"): + pytest.skip("skipping windows-only tests", allow_module_level=True) + +from ctypes import windll, wintypes + +from bleak.backends.winrt.util import _check_hresult, assert_mta +from bleak.exc import BleakError + +# https://learn.microsoft.com/en-us/windows/win32/api/objbase/ne-objbase-coinit +COINIT_MULTITHREADED = 0x0 +COINIT_APARTMENTTHREADED = 0x2 + +# https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex +_CoInitializeEx = windll.ole32.CoInitializeEx +_CoInitializeEx.restype = wintypes.LONG +_CoInitializeEx.argtypes = [wintypes.LPVOID, wintypes.DWORD] +_CoInitializeEx.errcheck = _check_hresult + +# https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-couninitialize +_CoUninitialize = windll.ole32.CoUninitialize +_CoUninitialize.restype = None +_CoUninitialize.argtypes = [] + + +@pytest.mark.asyncio +async def test_assert_mta_no_init(): + """Test device_path_from_characteristic_path.""" + + await assert_mta() + + +@pytest.mark.asyncio +async def test_assert_mta_init_mta(): + """Test device_path_from_characteristic_path.""" + + _CoInitializeEx(None, COINIT_MULTITHREADED) + + try: + await assert_mta() + assert hasattr(assert_mta, "_allowed") + finally: + _CoUninitialize() + + +@pytest.mark.asyncio +async def test_assert_mta_init_sta(): + """Test device_path_from_characteristic_path.""" + + _CoInitializeEx(None, COINIT_APARTMENTTHREADED) + + try: + with pytest.raises( + BleakError, + match="Thread is configured for Windows GUI but callbacks are not working.", + ): + await assert_mta() + finally: + _CoUninitialize() From 26a789356c65e4f7e292704a183541bdb0f171c3 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 17:12:50 -0500 Subject: [PATCH 04/11] fix uninitialize_sta --- bleak/backends/winrt/util.py | 9 +++++++-- tests/bleak/backends/winrt/test_utils.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/bleak/backends/winrt/util.py b/bleak/backends/winrt/util.py index a127c903..9b1b6151 100644 --- a/bleak/backends/winrt/util.py +++ b/bleak/backends/winrt/util.py @@ -186,7 +186,12 @@ def uninitialize_sta(): .. versionadded:: 0.22 """ + try: - assert_mta() - except BleakError: + _get_apartment_type() + except OSError as e: + # All is OK if not initialized yet. WinRT will initialize it. + if e.winerror == _CO_E_NOTINITIALIZED: + return + else: ctypes.windll.ole32.CoUninitialize() diff --git a/tests/bleak/backends/winrt/test_utils.py b/tests/bleak/backends/winrt/test_utils.py index 853a6e25..ba8e4936 100644 --- a/tests/bleak/backends/winrt/test_utils.py +++ b/tests/bleak/backends/winrt/test_utils.py @@ -11,7 +11,7 @@ from ctypes import windll, wintypes -from bleak.backends.winrt.util import _check_hresult, assert_mta +from bleak.backends.winrt.util import _check_hresult, assert_mta, uninitialize_sta from bleak.exc import BleakError # https://learn.microsoft.com/en-us/windows/win32/api/objbase/ne-objbase-coinit @@ -64,3 +64,13 @@ async def test_assert_mta_init_sta(): await assert_mta() finally: _CoUninitialize() + + +@pytest.mark.asyncio +async def test_uninitialize_sta(): + """Test device_path_from_characteristic_path.""" + + _CoInitializeEx(None, COINIT_APARTMENTTHREADED) + uninitialize_sta() + + await assert_mta() From ef0723ff9c88c92116934d9151cc72d84419536f Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 17:19:24 -0500 Subject: [PATCH 05/11] fix doc builds --- bleak/backends/winrt/util.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bleak/backends/winrt/util.py b/bleak/backends/winrt/util.py index 9b1b6151..afdfcd2f 100644 --- a/bleak/backends/winrt/util.py +++ b/bleak/backends/winrt/util.py @@ -28,10 +28,7 @@ def _check_hresult(result, func, args): # not defined in wintypes -if ctypes.sizeof(ctypes.c_long) == ctypes.sizeof(ctypes.c_void_p): - _UINT_PTR = ctypes.c_ulong -elif ctypes.sizeof(ctypes.c_longlong) == ctypes.sizeof(ctypes.c_void_p): - _UINT_PTR = ctypes.c_ulonglong +_UINT_PTR = wintypes.WPARAM # https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-timerproc _TIMERPROC = ctypes.WINFUNCTYPE( From 2b48574df216a13958bcfd59015ac88b0b5d45fe Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 17:39:50 -0500 Subject: [PATCH 06/11] update docs --- bleak/backends/winrt/util.py | 7 +++++- docs/troubleshooting.rst | 43 +++++++++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/bleak/backends/winrt/util.py b/bleak/backends/winrt/util.py index afdfcd2f..915abb3f 100644 --- a/bleak/backends/winrt/util.py +++ b/bleak/backends/winrt/util.py @@ -100,11 +100,16 @@ async def assert_mta() -> None: Asserts that the current apartment type is MTA. Raises: - BleakError: If the current apartment type is not MTA. + BleakError: + If the current apartment type is not MTA and there is no Windows + message loop running. .. versionadded:: 0.22 .. versionchanged:: unreleased + + Function is now async and will not raise if the current apartment type + is STA and the Windows message loop is running. """ if hasattr(allow_sta, "_allowed"): return diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 7cc88cdf..5a2ec28d 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -177,15 +177,32 @@ Not working when threading model is STA Packages like ``pywin32`` and it's subsidiaries have an unfortunate side effect of initializing the threading model to Single Threaded Apartment (STA) when -imported. This causes async WinRT functions to never complete. because there -isn't a message loop running. Bleak needs to run in a Multi Threaded Apartment -(MTA) instead (this happens automatically on the first WinRT call). +imported. This causes async WinRT functions to never complete if Bleak is being +used in a console application (no Windows graphical user interface). This is +because there isn't a Windows message loop running to handle async callbacks. +Bleak, when used in a console application, needs to run in a Multi Threaded +Apartment (MTA) instead (this happens automatically on the first WinRT call). Bleak should detect this and raise an exception with a message similar to:: Thread is configured for Windows GUI but callbacks are not working. -To work around this, you can use one of the utility functions provided by Bleak. +You can tell a ``pywin32`` package caused the issue by checking for +``"pythoncom" in sys.modules``. If it is there, then likely it triggered the +problem. You can avoid this by setting ``sys.coinit_flags = 0`` before importing +any package that indirectly imports ``pythoncom``. This will cause ``pythoncom`` +to use the default threading model (MTA) instead of STA. + +Example:: + + import sys + sys.coinit_flags = 0 # 0 means MTA + + import win32com # or any other package that causes the issue + + +If the issue was caused by something other than the ``pythoncom`` module, there +are a couple of other helper functions you can try. If your program has a graphical user interface and the UI framework *and* it is properly integrated with asyncio *and* Bleak is not running on a background @@ -201,14 +218,20 @@ thread then call ``allow_sta()`` before calling any other Bleak APis:: # can safely ignore pass -The more typical case, though, is that some library has imported something like -``win32com`` which breaks Bleak. In this case, you can uninitialize the threading -model like this:: +The more typical case, though, is that some library has imported something similar +to ``pythoncom`` with the same unwanted side effect of initializing the main +thread of a console application to STA. In this case, you can uninitialize the +threading model like this:: - import win32com # this sets current thread to STA :-( - from bleak.backends.winrt.util import uninitialize_sta + import naughty_module # this sets current thread to STA :-( - uninitialize_sta() # undo the unwanted side effect + try: + from bleak.backends.winrt.util import uninitialize_sta + + uninitialize_sta() # undo the unwanted side effect + except ImportError: + # not Windows, so no problem + pass -------------- From 2b6c49f6fe0ccb192c6125101aa52aa96e51eae5 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 17:47:28 -0500 Subject: [PATCH 07/11] check for pythoncom --- bleak/backends/winrt/util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bleak/backends/winrt/util.py b/bleak/backends/winrt/util.py index 915abb3f..0c10408a 100644 --- a/bleak/backends/winrt/util.py +++ b/bleak/backends/winrt/util.py @@ -147,7 +147,12 @@ def wait_event(*_): await event.wait() except asyncio.TimeoutError: raise BleakError( - "Thread is configured for Windows GUI but callbacks are not working. Suspect PyWin32 unwanted side effects." + "Thread is configured for Windows GUI but callbacks are not working." + + ( + " Suspect unwanted side effects from importing 'pythoncom'." + if "pythoncom" in sys.modules + else "" + ) ) else: # if the windows event loop is running, we assume it is going to keep From d3685a22ff3a30b79696e14c196eab9159224b34 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 18:01:13 -0500 Subject: [PATCH 08/11] fix setting wrong attribute --- bleak/backends/winrt/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bleak/backends/winrt/util.py b/bleak/backends/winrt/util.py index 0c10408a..accb0087 100644 --- a/bleak/backends/winrt/util.py +++ b/bleak/backends/winrt/util.py @@ -126,7 +126,7 @@ async def assert_mta() -> None: if apt_type == _AptType.MTA: # if we get here, WinRT probably set the apartment type to MTA and all # is well, we don't need to check again - setattr(assert_mta, "_allowed", True) + setattr(allow_sta, "_allowed", True) return event = asyncio.Event() @@ -157,7 +157,7 @@ def wait_event(*_): else: # if the windows event loop is running, we assume it is going to keep # running and we don't need to check again - setattr(assert_mta, "_allowed", True) + setattr(allow_sta, "_allowed", True) finally: _KillTimer(None, timer) From c987aaa3042e556de8ed3f94da06b9b6c06e5cdc Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 18:03:04 -0500 Subject: [PATCH 09/11] reduce timeout --- bleak/backends/winrt/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bleak/backends/winrt/util.py b/bleak/backends/winrt/util.py index accb0087..503b7178 100644 --- a/bleak/backends/winrt/util.py +++ b/bleak/backends/winrt/util.py @@ -143,7 +143,7 @@ def wait_event(*_): timer = _SetTimer(None, 1, 0, callback) try: - async with async_timeout(1): + async with async_timeout(0.5): await event.wait() except asyncio.TimeoutError: raise BleakError( From 1f88e87fe8829a412b196739312536cc1ddf9715 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 18:05:32 -0500 Subject: [PATCH 10/11] fix more wrong attribute --- tests/bleak/backends/winrt/test_utils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/bleak/backends/winrt/test_utils.py b/tests/bleak/backends/winrt/test_utils.py index ba8e4936..357f3189 100644 --- a/tests/bleak/backends/winrt/test_utils.py +++ b/tests/bleak/backends/winrt/test_utils.py @@ -11,7 +11,12 @@ from ctypes import windll, wintypes -from bleak.backends.winrt.util import _check_hresult, assert_mta, uninitialize_sta +from bleak.backends.winrt.util import ( + _check_hresult, + allow_sta, + assert_mta, + uninitialize_sta, +) from bleak.exc import BleakError # https://learn.microsoft.com/en-us/windows/win32/api/objbase/ne-objbase-coinit @@ -45,7 +50,7 @@ async def test_assert_mta_init_mta(): try: await assert_mta() - assert hasattr(assert_mta, "_allowed") + assert hasattr(allow_sta, "_allowed") finally: _CoUninitialize() From c9eeb0c2b006b7f2721912ddf1c645bf6e6e0241 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 24 May 2024 18:12:12 -0500 Subject: [PATCH 11/11] fix tests --- tests/bleak/backends/winrt/test_utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/bleak/backends/winrt/test_utils.py b/tests/bleak/backends/winrt/test_utils.py index 357f3189..3c90dbcc 100644 --- a/tests/bleak/backends/winrt/test_utils.py +++ b/tests/bleak/backends/winrt/test_utils.py @@ -35,6 +35,17 @@ _CoUninitialize.argtypes = [] +@pytest.fixture(autouse=True) +def run_around_tests(): + # reset global state + try: + delattr(allow_sta, "_allowed") + except AttributeError: + pass + + yield + + @pytest.mark.asyncio async def test_assert_mta_no_init(): """Test device_path_from_characteristic_path."""