From 940fd8a604e90bcf1993163b5ed17fe715f96453 Mon Sep 17 00:00:00 2001 From: Koji Ishii Date: Sun, 23 Mar 2025 15:57:15 +0900 Subject: [PATCH 1/9] Create `WindowsVCP` for each physical monitor instead of logical --- monitorcontrol/vcp/vcp_windows.py | 179 ++++++++++++++++++------------ tests/test_windows_vcp.py | 28 +++++ 2 files changed, 134 insertions(+), 73 deletions(-) create mode 100644 tests/test_windows_vcp.py diff --git a/monitorcontrol/vcp/vcp_windows.py b/monitorcontrol/vcp/vcp_windows.py index fb37e2a..9a12da1 100644 --- a/monitorcontrol/vcp/vcp_windows.py +++ b/monitorcontrol/vcp/vcp_windows.py @@ -1,6 +1,6 @@ from .vcp_abc import VCP, VCPError from types import TracebackType -from typing import List, Optional, Tuple, Type +from typing import Callable, List, Optional, Tuple, Type import ctypes import logging import sys @@ -31,53 +31,21 @@ class WindowsVCP(VCP): https://stackoverflow.com/questions/16588133/ """ - def __init__(self, hmonitor: HMONITOR): + def __init__(self, handle: HANDLE, description: str): """ Args: - hmonitor: logical monitor handle + handle: Handle to the physical monitor. + description: Text description of the physical monitor. """ self.logger = logging.getLogger(__name__) - self.hmonitor = hmonitor + self.handle = handle + self.description = description - def __enter__(self): - num_physical = DWORD() - self.logger.debug("GetNumberOfPhysicalMonitorsFromHMONITOR") - try: - if not ctypes.windll.dxva2.GetNumberOfPhysicalMonitorsFromHMONITOR( - self.hmonitor, ctypes.byref(num_physical) - ): - raise VCPError( - "Call to GetNumberOfPhysicalMonitorsFromHMONITOR failed: " - + ctypes.FormatError() - ) - except OSError as e: - raise VCPError( - "Call to GetNumberOfPhysicalMonitorsFromHMONITOR failed" - ) from e - - if num_physical.value == 0: - raise VCPError("no physical monitor found") - elif num_physical.value > 1: - # TODO: Figure out a clever way around the Windows API since - # it does not allow opening and closing of individual physical - # monitors without their hmonitors. - raise VCPError("more than one physical monitor per hmonitor") + def __del__(self): + WindowsVCP._DestroyPhysicalMonitor(self.handle) - physical_monitors = (PhysicalMonitor * num_physical.value)() - self.logger.debug("GetPhysicalMonitorsFromHMONITOR") - try: - if not ctypes.windll.dxva2.GetPhysicalMonitorsFromHMONITOR( - self.hmonitor, num_physical.value, physical_monitors - ): - raise VCPError( - "Call to GetPhysicalMonitorsFromHMONITOR failed: " - + ctypes.FormatError() - ) - except OSError as e: - raise VCPError("failed to open physical monitor handle") from e - self.handle = physical_monitors[0].handle - self.description = physical_monitors[0].description - return self + def __enter__(self): + pass def __exit__( self, @@ -85,14 +53,6 @@ def __exit__( exception_value: Optional[BaseException], exception_traceback: Optional[TracebackType], ) -> Optional[bool]: - self.logger.debug("DestroyPhysicalMonitor") - try: - if not ctypes.windll.dxva2.DestroyPhysicalMonitor(self.handle): - raise VCPError( - "Call to DestroyPhysicalMonitor failed: " + ctypes.FormatError() - ) - except OSError as e: - raise VCPError("failed to close handle") from e return False def set_vcp_feature(self, code: int, value: int): @@ -190,6 +150,95 @@ def get_vcp_capabilities(self): raise VCPError("failed to get VCP capabilities") from e return cap_string.value.decode("ascii") + @staticmethod + def _get_physical_monitors( + get_hmonitors: Callable[[], List[HMONITOR]], + physical_monitors_from_hmonitor: Callable[ + [HMONITOR], List[Tuple[HANDLE, str]] + ], + ): + """ + Returns a list of physical monitors. + + Underlying Windows APIs are given as `Callable`s to make this + function testable. + """ + return ( + physical_monitor + for hmonitor in get_hmonitors() + for physical_monitor in physical_monitors_from_hmonitor(hmonitor) + ) + + @staticmethod + def _EnumDisplayMonitors() -> List[HMONITOR]: + """ + Calls the Windows `EnumDisplayMonitors` API in Python-friendly form. + """ + hmonitors = [] # type: List[HMONITOR] + try: + + def _callback(hmonitor, hdc, lprect, lparam): + hmonitors.append(HMONITOR(hmonitor)) + del hmonitor, hdc, lprect, lparam + return True # continue enumeration + + MONITORENUMPROC = ctypes.WINFUNCTYPE( # noqa: N806 + BOOL, HMONITOR, HDC, ctypes.POINTER(RECT), LPARAM + ) + callback = MONITORENUMPROC(_callback) + if not ctypes.windll.user32.EnumDisplayMonitors(0, 0, callback, 0): + raise VCPError("Call to EnumDisplayMonitors failed") + except OSError as e: + raise VCPError("failed to enumerate VCPs") from e + return hmonitors + + @staticmethod + def _GetPhysicalMonitorsFromHMONITOR( + hmonitor: HMONITOR, + ) -> List[Tuple[HANDLE, str]]: + """ + Calls the Windows `GetPhysicalMonitorsFromHMONITOR` API in Python-friendly form. + """ + num_physical = DWORD() + try: + if not ctypes.windll.dxva2.GetNumberOfPhysicalMonitorsFromHMONITOR( + hmonitor, ctypes.byref(num_physical) + ): + raise VCPError( + "Call to GetNumberOfPhysicalMonitorsFromHMONITOR failed: " + + ctypes.FormatError() + ) + except OSError as e: + raise VCPError( + "Call to GetNumberOfPhysicalMonitorsFromHMONITOR failed" + ) from e + + physical_monitors = (PhysicalMonitor * num_physical.value)() + try: + if not ctypes.windll.dxva2.GetPhysicalMonitorsFromHMONITOR( + hmonitor, num_physical.value, physical_monitors + ): + raise VCPError( + "Call to GetPhysicalMonitorsFromHMONITOR failed: " + + ctypes.FormatError() + ) + except OSError as e: + raise VCPError("failed to open physical monitor handle") from e + return list( + [physical_monitor.handle, physical_monitor.description] + for physical_monitor in physical_monitors + ) + + @staticmethod + def _DestroyPhysicalMonitor(handle: HANDLE) -> None: + try: + if not ctypes.windll.dxva2.DestroyPhysicalMonitor(handle): + raise VCPError( + "Call to DestroyPhysicalMonitor failed: " + ctypes.FormatError() + ) + except OSError as e: + raise VCPError("failed to close handle") from e + def get_vcps() -> List[WindowsVCP]: """ Opens handles to all physical VCPs. @@ -200,26 +249,10 @@ def get_vcps() -> List[WindowsVCP]: Raises: VCPError: Failed to enumerate VCPs. """ - vcps = [] - hmonitors = [] - - try: - - def _callback(hmonitor, hdc, lprect, lparam): - hmonitors.append(HMONITOR(hmonitor)) - del hmonitor, hdc, lprect, lparam - return True # continue enumeration - - MONITORENUMPROC = ctypes.WINFUNCTYPE( # noqa: N806 - BOOL, HMONITOR, HDC, ctypes.POINTER(RECT), LPARAM - ) - callback = MONITORENUMPROC(_callback) - if not ctypes.windll.user32.EnumDisplayMonitors(0, 0, callback, 0): - raise VCPError("Call to EnumDisplayMonitors failed") - except OSError as e: - raise VCPError("failed to enumerate VCPs") from e - - for logical in hmonitors: - vcps.append(WindowsVCP(logical)) - - return vcps + physical_monitors = WindowsVCP._get_physical_monitors( + WindowsVCP._EnumDisplayMonitors, WindowsVCP._GetPhysicalMonitorsFromHMONITOR + ) + return list( + WindowsVCP(handle, description) + for (handle, description) in physical_monitors + ) diff --git a/tests/test_windows_vcp.py b/tests/test_windows_vcp.py new file mode 100644 index 0000000..4e3f9b5 --- /dev/null +++ b/tests/test_windows_vcp.py @@ -0,0 +1,28 @@ +import pytest +import sys +from monitorcontrol.vcp.vcp_windows import WindowsVCP + + +if sys.platform == "win32": + + @pytest.mark.parametrize( + "input,expected", + [ + [[1], ["1-0"]], + [[2], ["2-0", "2-1"]], + [[1, 2], ["1-0", "2-0", "2-1"]], + [[1, 3], ["1-0", "3-0", "3-1", "3-2"]], + ], + ) + def test_get_physical_monitors(input, expected): + physical_monitors = { + 1: ["1-0"], + 2: ["2-0", "2-1"], + 3: ["3-0", "3-1", "3-2"], + } + result = list( + WindowsVCP._get_physical_monitors( + lambda: input, lambda hmonitor: physical_monitors.get(hmonitor) + ) + ) + assert result == expected From 72f7a7650639991ffb00ccd24983f91ef5d9898a Mon Sep 17 00:00:00 2001 From: Koji Ishii Date: Sun, 23 Mar 2025 16:38:21 +0900 Subject: [PATCH 2/9] Fixes --- monitorcontrol/vcp/vcp_windows.py | 6 +++--- tests/test_windows_vcp.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/monitorcontrol/vcp/vcp_windows.py b/monitorcontrol/vcp/vcp_windows.py index 9a12da1..144dd49 100644 --- a/monitorcontrol/vcp/vcp_windows.py +++ b/monitorcontrol/vcp/vcp_windows.py @@ -170,7 +170,7 @@ def _get_physical_monitors( ) @staticmethod - def _EnumDisplayMonitors() -> List[HMONITOR]: + def _get_hmonitors() -> List[HMONITOR]: """ Calls the Windows `EnumDisplayMonitors` API in Python-friendly form. """ @@ -193,7 +193,7 @@ def _callback(hmonitor, hdc, lprect, lparam): return hmonitors @staticmethod - def _GetPhysicalMonitorsFromHMONITOR( + def _physical_monitors_from_hmonitor( hmonitor: HMONITOR, ) -> List[Tuple[HANDLE, str]]: """ @@ -250,7 +250,7 @@ def get_vcps() -> List[WindowsVCP]: VCPError: Failed to enumerate VCPs. """ physical_monitors = WindowsVCP._get_physical_monitors( - WindowsVCP._EnumDisplayMonitors, WindowsVCP._GetPhysicalMonitorsFromHMONITOR + WindowsVCP._get_hmonitors, WindowsVCP._physical_monitors_from_hmonitor ) return list( WindowsVCP(handle, description) diff --git a/tests/test_windows_vcp.py b/tests/test_windows_vcp.py index 4e3f9b5..5c619bd 100644 --- a/tests/test_windows_vcp.py +++ b/tests/test_windows_vcp.py @@ -1,9 +1,9 @@ import pytest import sys -from monitorcontrol.vcp.vcp_windows import WindowsVCP if sys.platform == "win32": + from monitorcontrol.vcp.vcp_windows import WindowsVCP @pytest.mark.parametrize( "input,expected", From 2a6b05c1cfb95466a077e5647a70179c88d039fb Mon Sep 17 00:00:00 2001 From: Koji Ishii Date: Sun, 23 Mar 2025 16:39:50 +0900 Subject: [PATCH 3/9] Style fix --- monitorcontrol/vcp/vcp_windows.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/monitorcontrol/vcp/vcp_windows.py b/monitorcontrol/vcp/vcp_windows.py index 144dd49..0e368cb 100644 --- a/monitorcontrol/vcp/vcp_windows.py +++ b/monitorcontrol/vcp/vcp_windows.py @@ -42,7 +42,7 @@ def __init__(self, handle: HANDLE, description: str): self.description = description def __del__(self): - WindowsVCP._DestroyPhysicalMonitor(self.handle) + WindowsVCP._destroy_physical_monitor(self.handle) def __enter__(self): pass @@ -230,7 +230,10 @@ def _physical_monitors_from_hmonitor( ) @staticmethod - def _DestroyPhysicalMonitor(handle: HANDLE) -> None: + def _destroy_physical_monitor(handle: HANDLE) -> None: + """ + Calls the Windows `DestroyPhysicalMonitor` API in Python-friendly form. + """ try: if not ctypes.windll.dxva2.DestroyPhysicalMonitor(handle): raise VCPError( From b974640d6a03584da9fee8460b28f034a9fec1ac Mon Sep 17 00:00:00 2001 From: Koji Ishii Date: Sun, 23 Mar 2025 17:47:41 +0900 Subject: [PATCH 4/9] Add type hint to `_get_physical_monitors` --- monitorcontrol/vcp/vcp_windows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monitorcontrol/vcp/vcp_windows.py b/monitorcontrol/vcp/vcp_windows.py index 0e368cb..3057442 100644 --- a/monitorcontrol/vcp/vcp_windows.py +++ b/monitorcontrol/vcp/vcp_windows.py @@ -156,7 +156,7 @@ def _get_physical_monitors( physical_monitors_from_hmonitor: Callable[ [HMONITOR], List[Tuple[HANDLE, str]] ], - ): + ) -> List[Tuple[HANDLE, str]]: """ Returns a list of physical monitors. From e750d9cb537f564417044a9ba74aa4f23b04f794 Mon Sep 17 00:00:00 2001 From: Koji Ishii Date: Sun, 23 Mar 2025 19:45:26 +0900 Subject: [PATCH 5/9] Remove unnecessary generations --- monitorcontrol/vcp/vcp_windows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monitorcontrol/vcp/vcp_windows.py b/monitorcontrol/vcp/vcp_windows.py index 3057442..14f5432 100644 --- a/monitorcontrol/vcp/vcp_windows.py +++ b/monitorcontrol/vcp/vcp_windows.py @@ -224,7 +224,7 @@ def _physical_monitors_from_hmonitor( ) except OSError as e: raise VCPError("failed to open physical monitor handle") from e - return list( + return ( [physical_monitor.handle, physical_monitor.description] for physical_monitor in physical_monitors ) From 640e2b422b59ec4bea99d6033a35cfdc82d0b422 Mon Sep 17 00:00:00 2001 From: Koji Ishii Date: Mon, 31 Mar 2025 03:04:49 +0900 Subject: [PATCH 6/9] Use `unittest.mock` in tests --- monitorcontrol/vcp/vcp_windows.py | 17 ++++++----------- tests/test_windows_vcp.py | 16 +++++++++++----- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/monitorcontrol/vcp/vcp_windows.py b/monitorcontrol/vcp/vcp_windows.py index 14f5432..9f5a15b 100644 --- a/monitorcontrol/vcp/vcp_windows.py +++ b/monitorcontrol/vcp/vcp_windows.py @@ -151,12 +151,7 @@ def get_vcp_capabilities(self): return cap_string.value.decode("ascii") @staticmethod - def _get_physical_monitors( - get_hmonitors: Callable[[], List[HMONITOR]], - physical_monitors_from_hmonitor: Callable[ - [HMONITOR], List[Tuple[HANDLE, str]] - ], - ) -> List[Tuple[HANDLE, str]]: + def _get_physical_monitors() -> List[Tuple[HANDLE, str]]: """ Returns a list of physical monitors. @@ -165,8 +160,10 @@ def _get_physical_monitors( """ return ( physical_monitor - for hmonitor in get_hmonitors() - for physical_monitor in physical_monitors_from_hmonitor(hmonitor) + for hmonitor in WindowsVCP._get_hmonitors() + for physical_monitor in WindowsVCP._physical_monitors_from_hmonitor( + hmonitor + ) ) @staticmethod @@ -252,9 +249,7 @@ def get_vcps() -> List[WindowsVCP]: Raises: VCPError: Failed to enumerate VCPs. """ - physical_monitors = WindowsVCP._get_physical_monitors( - WindowsVCP._get_hmonitors, WindowsVCP._physical_monitors_from_hmonitor - ) + physical_monitors = WindowsVCP._get_physical_monitors() return list( WindowsVCP(handle, description) for (handle, description) in physical_monitors diff --git a/tests/test_windows_vcp.py b/tests/test_windows_vcp.py index 5c619bd..7f591c3 100644 --- a/tests/test_windows_vcp.py +++ b/tests/test_windows_vcp.py @@ -1,5 +1,6 @@ import pytest import sys +from unittest.mock import patch if sys.platform == "win32": @@ -14,15 +15,20 @@ [[1, 3], ["1-0", "3-0", "3-1", "3-2"]], ], ) - def test_get_physical_monitors(input, expected): + @patch("monitorcontrol.vcp.vcp_windows.WindowsVCP._get_hmonitors") + @patch("monitorcontrol.vcp.vcp_windows.WindowsVCP._physical_monitors_from_hmonitor") + def test_get_physical_monitors( + physical_monitors_from_hmonitor, get_hmonitors, input, expected + ): + get_hmonitors.return_value = input physical_monitors = { 1: ["1-0"], 2: ["2-0", "2-1"], 3: ["3-0", "3-1", "3-2"], } - result = list( - WindowsVCP._get_physical_monitors( - lambda: input, lambda hmonitor: physical_monitors.get(hmonitor) - ) + physical_monitors_from_hmonitor.side_effect = ( + lambda hmonitor: physical_monitors.get(hmonitor) ) + print(WindowsVCP._get_hmonitors()) + result = list(WindowsVCP._get_physical_monitors()) assert result == expected From b3b4d12f8289989903dd53aecb575f48f3613bf9 Mon Sep 17 00:00:00 2001 From: Koji Ishii Date: Mon, 31 Mar 2025 03:11:29 +0900 Subject: [PATCH 7/9] Update comments and type hits --- monitorcontrol/vcp/vcp_windows.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/monitorcontrol/vcp/vcp_windows.py b/monitorcontrol/vcp/vcp_windows.py index 9f5a15b..b6e808b 100644 --- a/monitorcontrol/vcp/vcp_windows.py +++ b/monitorcontrol/vcp/vcp_windows.py @@ -1,6 +1,6 @@ from .vcp_abc import VCP, VCPError from types import TracebackType -from typing import Callable, List, Optional, Tuple, Type +from typing import Iterator, List, Optional, Tuple, Type import ctypes import logging import sys @@ -151,12 +151,9 @@ def get_vcp_capabilities(self): return cap_string.value.decode("ascii") @staticmethod - def _get_physical_monitors() -> List[Tuple[HANDLE, str]]: + def _get_physical_monitors() -> Iterator[Tuple[HANDLE, str]]: """ Returns a list of physical monitors. - - Underlying Windows APIs are given as `Callable`s to make this - function testable. """ return ( physical_monitor @@ -192,7 +189,7 @@ def _callback(hmonitor, hdc, lprect, lparam): @staticmethod def _physical_monitors_from_hmonitor( hmonitor: HMONITOR, - ) -> List[Tuple[HANDLE, str]]: + ) -> Iterator[Tuple[HANDLE, str]]: """ Calls the Windows `GetPhysicalMonitorsFromHMONITOR` API in Python-friendly form. """ From f84ad3804a16062939f17fb3556757de8eeb2abd Mon Sep 17 00:00:00 2001 From: Koji Ishii Date: Mon, 31 Mar 2025 03:14:16 +0900 Subject: [PATCH 8/9] Remove a debug code --- tests/test_windows_vcp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_windows_vcp.py b/tests/test_windows_vcp.py index 7f591c3..22aa131 100644 --- a/tests/test_windows_vcp.py +++ b/tests/test_windows_vcp.py @@ -29,6 +29,5 @@ def test_get_physical_monitors( physical_monitors_from_hmonitor.side_effect = ( lambda hmonitor: physical_monitors.get(hmonitor) ) - print(WindowsVCP._get_hmonitors()) result = list(WindowsVCP._get_physical_monitors()) assert result == expected From 68b80028609715370aa9fdadf73468394bed7aad Mon Sep 17 00:00:00 2001 From: Alex Martens Date: Mon, 26 May 2025 10:17:42 -0700 Subject: [PATCH 9/9] fix shadowed builtin --- tests/test_windows_vcp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_windows_vcp.py b/tests/test_windows_vcp.py index 22aa131..870ca54 100644 --- a/tests/test_windows_vcp.py +++ b/tests/test_windows_vcp.py @@ -7,7 +7,7 @@ from monitorcontrol.vcp.vcp_windows import WindowsVCP @pytest.mark.parametrize( - "input,expected", + "monitor_input, expected", [ [[1], ["1-0"]], [[2], ["2-0", "2-1"]], @@ -18,9 +18,9 @@ @patch("monitorcontrol.vcp.vcp_windows.WindowsVCP._get_hmonitors") @patch("monitorcontrol.vcp.vcp_windows.WindowsVCP._physical_monitors_from_hmonitor") def test_get_physical_monitors( - physical_monitors_from_hmonitor, get_hmonitors, input, expected + physical_monitors_from_hmonitor, get_hmonitors, monitor_input, expected ): - get_hmonitors.return_value = input + get_hmonitors.return_value = monitor_input physical_monitors = { 1: ["1-0"], 2: ["2-0", "2-1"],