asweigart / pyautogui

A cross-platform GUI automation Python module for human beings. Used to programmatically control the mouse & keyboard.
BSD 3-Clause "New" or "Revised" License
10.32k stars 1.25k forks source link

Importing pyautogui breaks monitor DPI detection with ctypes #663

Open GeorgeWashingtonABCDEFG opened 2 years ago

GeorgeWashingtonABCDEFG commented 2 years ago
import ctypes
# import pyautogui
dpix = ctypes.c_uint()
dpiy = ctypes.c_uint()
ctypes.windll.shcore.SetProcessDpiAwareness(2)
ctypes.windll.shcore.GetDpiForMonitor(1186359,0,ctypes.byref(dpix),ctypes.byref(dpiy))
print(dpix.value)

# output
144 # this is correct

import ctypes
import pyautogui
dpix = ctypes.c_uint()
dpiy = ctypes.c_uint()
ctypes.windll.shcore.SetProcessDpiAwareness(2)
ctypes.windll.shcore.GetDpiForMonitor(1186359,0,ctypes.byref(dpix),ctypes.byref(dpiy))
print(dpix.value)

# output
96 # this is wrong. The monitor's dpi is 144. Note the monitor is scaled in windows.
# Everything I do about detecting window size, location, etc is all broken by simply importing your module.
bersbersbers commented 1 year ago

Here's something related:

"""
QtWarningMsg:
setProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) failed:
COM error 0x5  (Access is denied.)
"""
import pyautogui  # noqa # pylint:disable=unused-import
from PySide6.QtWidgets import QApplication

QApplication()

It outputs the warning

qt.qpa.windows: setProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) failed: COM error 0x5 (Access is denied.)

According to MS docs,

Possible errors are [..] ERROR_ACCESS_DENIED if the default API awareness mode for the process has already been set (via a previous API call or within the application manifest).

https://learn.microsoft.com/th-th/windows/win32/api/winuser/nf-winuser-setprocessdpiawarenesscontext

So it seems pyautogui sets DPI awareness somewhere before. I find it in https://github.com/asweigart/pyautogui/blob/ad7609c6c277831e5fb5809272139f7db8ccdbeb/pyautogui/_pyautogui_win.py#L14-L18

I am considering monkey-patching ctypes before importing pyautogui to remove that call.

bersbersbers commented 1 year ago

Here's a workaround:

"""Workaround."""
import ctypes

import pytest
from PySide6.QtWidgets import QApplication

with pytest.MonkeyPatch.context() as mp:
    mp.setattr(ctypes.windll.user32, "SetProcessDPIAware", lambda: None)
    import pyautogui  # noqa # pylint:disable=unused-import

QApplication()
bersbersbers commented 1 year ago

Much nicer workaround with the standard library:

"""Workaround."""
from unittest.mock import patch
from PySide6.QtWidgets import QApplication
with patch("ctypes.windll.user32.SetProcessDPIAware", autospec=True):
    import pyautogui  # noqa # pylint:disable=unused-import

QApplication()
bersbersbers commented 1 year ago

Importantly, that Qt warning message will go away: https://bugreports.qt.io/browse/PYSIDE-2105

This will solve my issue (and make my patch pointless), but it will not solve @GeorgeWashingtonABCDEFG's issue (for which my patch may still be useful).

In the end, pyautogui should probably stop calling SetProcessDPIAware unless necessary (and if so, call it as late as possible).

bersbersbers commented 1 year ago

Related: https://github.com/asweigart/pyautogui/commit/9cd04d3405d3bbb2c477f9b35ae5fbaabc3acce2

Avasam commented 1 year ago

Much nicer workaround with the standard library:

"""Workaround."""
from unittest.mock import patch
from PySide6.QtWidgets import QApplication
with patch("ctypes.windll.user32.SetProcessDPIAware", autospec=True):
    import pyautogui  # noqa # pylint:disable=unused-import

QApplication()

The unittest workaround significantly increases build time, boot time and build size with PyInstaller. So I went with some sort of postinstall powershell script that patches all offending libraries, and uninstalls those I don't actually need.

# Prevent pyautogui and pywinctl from setting Process DPI Awareness, which Qt tries to do then throws warnings about it.
# The unittest workaround significantly increases build time, boot time and build size with PyInstaller.
# https://github.com/asweigart/pyautogui/issues/663#issuecomment-1296719464
$libPath = python -c 'import pyautogui as _; print(_.__path__[0])'
(Get-Content "$libPath/_pyautogui_win.py").replace('ctypes.windll.user32.SetProcessDPIAware()', 'pass') |
  Set-Content "$libPath/_pyautogui_win.py"
$libPath = python -c 'import pymonctl as _; print(_.__path__[0])'
(Get-Content "$libPath/_pymonctl_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness', 'pass # ') |
  Set-Content "$libPath/_pymonctl_win.py"
$libPath = python -c 'import pywinbox as _; print(_.__path__[0])'
(Get-Content "$libPath/_pywinbox_win.py").replace('ctypes.windll.shcore.SetProcessDpiAwareness', 'pass # ') |
  Set-Content "$libPath/_pywinbox_win.py"
# Uninstall optional dependencies that PyInstaller picks up
python -m pip uninstall pyscreeze mouseinfo pyperclip -y

To find where it's called in the first place, I simply added the following at the top of my main/entrypoint module:

# flake8: noqa
def find_culprit(*_):
    raise Exception
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness = find_culprit
ctypes.windll.user32.SetProcessDPIAware = find_culprit
Avasam commented 4 months ago

I think I found an even better workaround. This is what I'm using now. If you did need SetProcessDPIAware or SetProcessDpiAwareness yourself, just assign it to a variable first.

import sys

# Prevent PyAutoGUI and pywinctl from setting Process DPI Awareness, which Qt tries to do then throws warnings about it.
# The unittest workaround significantly increases build time, boot time and build size with PyInstaller.
# https://github.com/asweigart/pyautogui/issues/663#issuecomment-1296719464
# QT doesn't call those from Python/ctypes, meaning we can stop other programs from setting it.
if sys.platform == "win32":
    import ctypes
    # pyautogui._pyautogui_win.py
    ctypes.windll.user32.SetProcessDPIAware = lambda: None  # pyright: ignore[reportAttributeAccessIssue]
    # pymonctl._pymonctl_win.py
    # pywinbox._pywinbox_win.py
    ctypes.windll.shcore.SetProcessDpiAwareness = (  # pyright: ignore[reportAttributeAccessIssue]
        lambda _: None  # pyright: ignore[reportUnknownLambdaType]
    )