diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6bcf7..5087f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ See Git checking messages for full history. - Linux: add the XShmGetImage backend with automatic XGetImage fallback and explicit status reporting (#431) - Windows: improve error checking and messages for Win32 API calls (#448) - Mac: fix memory leak (#450, #453) +- improve multithreading: allow multiple threads to use the same MSS object, allow multiple MSS objects to concurrently take screenshots, and document multithreading guarantees (#446, #452) - :heart: contributors: @jholveck ## 10.1.0 (2025-08-16) @@ -19,7 +20,7 @@ See Git checking messages for full history. ## 10.0.0 (2024-11-14) - removed support for Python 3.8 - added support for Python 3.14 -- Linux: fixed a threadding issue in `.close()` when calling `XCloseDisplay()` (#251) +- Linux: fixed a threading issue in `.close()` when calling `XCloseDisplay()` (#251) - Linux: minor optimization when checking for a X extension status (#251) - :heart: contributors: @kianmeng, @shravanasati, @mgorny diff --git a/docs/source/examples.rst b/docs/source/examples.rst index bf10cae..437e0f3 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -96,6 +96,11 @@ You can get the bytes of the PNG image: Advanced ======== +.. _custom_cls_image: + +Custom ScreenShot Subclass +-------------------------- + You can handle data using a custom class: .. literalinclude:: examples/custom_cls_image.py diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 75dc3c2..087fad9 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -54,12 +54,37 @@ This is a much better usage, memory efficient:: Also, it is a good thing to save the MSS instance inside an attribute of your class and calling it when needed. +Multithreading +============== + +MSS is thread-safe and can be used from multiple threads. + +**Sharing one MSS object**: You can use the same MSS object from multiple threads. Calls to +:py:meth:`mss.base.MSSBase.grab` (and other capture methods) are serialized automatically, meaning only one thread +will capture at a time. This may be relaxed in a future version if it can be done safely. + +**Using separate MSS objects**: You can also create different MSS objects in different threads. Whether these run +concurrently or are serialized by the OS depends on the platform. + +Custom :py:class:`mss.screenshot.ScreenShot` classes (see :ref:`custom_cls_image`) must **not** call +:py:meth:`mss.base.MSSBase.grab` in their constructor. + +.. danger:: + These guarantees do not apply to the obsolete Xlib backend, :py:mod:`mss.linux.xlib`. That backend is only used + if you specifically request it, so you won't be caught off-guard. + +.. versionadded:: 10.2.0 + Prior to this version, on some operating systems, the MSS object could only be used on the thread on which it was + created. + .. _backends: Backends --------- +======== -Some platforms have multiple ways to take screenshots. In MSS, these are known as *backends*. The :py:func:`mss` functions will normally autodetect which one is appropriate for your situation, but you can override this if you want. For instance, you may know that your specific situation requires a particular backend. +Some platforms have multiple ways to take screenshots. In MSS, these are known as *backends*. The :py:func:`mss` +functions will normally autodetect which one is appropriate for your situation, but you can override this if you want. +For instance, you may know that your specific situation requires a particular backend. If you want to choose a particular backend, you can use the :py:attr:`backend` keyword to :py:func:`mss`:: diff --git a/src/mss/base.py b/src/mss/base.py index 12b0627..80cc5d7 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -37,6 +37,10 @@ #: Global lock protecting access to platform screenshot calls. #: #: .. versionadded:: 6.0.0 +#: +#: .. deprecated:: 10.2.0 +#: The global lock is no longer used, and will be removed in a future release. +#: MSS objects now have their own locks, which are not publicly-accessible. lock = Lock() OPAQUE = 255 @@ -55,7 +59,7 @@ class MSSBase(metaclass=ABCMeta): ``compression_level``, ``display``, ``max_displays``, and ``with_cursor`` keyword arguments. """ - __slots__ = {"_closed", "_monitors", "cls_image", "compression_level", "with_cursor"} + __slots__ = {"_closed", "_lock", "_monitors", "cls_image", "compression_level", "with_cursor"} def __init__( self, @@ -69,18 +73,33 @@ def __init__( # Mac only max_displays: int = 32, # noqa: ARG002 ) -> None: + # The cls_image is only used atomically, so does not require locking. self.cls_image: type[ScreenShot] = ScreenShot + # The compression level is only used atomically, so does not require locking. #: PNG compression level used when saving the screenshot data into a file #: (see :py:func:`zlib.compress()` for details). #: #: .. versionadded:: 3.2.0 self.compression_level = compression_level + # The with_cursor attribute is not meant to be changed after initialization. #: Include the mouse cursor in screenshots. #: + #: In some circumstances, it may not be possible to include the cursor. In that case, MSS will automatically + #: change this to False when the object is created. + #: + #: This should not be changed after creating the object. + #: #: .. versionadded:: 8.0.0 self.with_cursor = with_cursor + + # The attributes below are protected by self._lock. The attributes above are user-visible, so we don't + # control when they're modified. Currently, we only make sure that they're safe to modify while locked, or + # document that the user shouldn't change them. We could also use properties protect them against changes, or + # change them under the lock. + self._lock = Lock() self._monitors: Monitors = [] self._closed = False + # If there isn't a factory that removed the "backend" argument, make sure that it was set to "default". # Factories that do backend-specific dispatch should remove that argument. if backend != "default": @@ -97,24 +116,33 @@ def __exit__(self, *_: object) -> None: @abstractmethod def _cursor_impl(self) -> ScreenShot | None: - """Retrieve all cursor data. Pixels have to be RGB.""" + """Retrieve all cursor data. Pixels have to be RGB. + + The object lock will be held when this method is called. + """ @abstractmethod def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: """Retrieve all pixels from a monitor. Pixels have to be RGB. - That method has to be run using a threading lock. + + The object lock will be held when this method is called. """ @abstractmethod def _monitors_impl(self) -> None: - """Get positions of monitors (has to be run using a threading lock). + """Get positions of monitors. + It must populate self._monitors. + + The object lock will be held when this method is called. """ def _close_impl(self) -> None: # noqa:B027 """Clean up. This will be called at most once. + + The object lock will be held when this method is called. """ # It's not necessary for subclasses to implement this if they have nothing to clean up. @@ -133,7 +161,7 @@ def close(self) -> None: with mss.mss() as sct: ... """ - with lock: + with self._lock: if self._closed: return self._close_impl() @@ -165,7 +193,7 @@ def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: msg = f"Region has zero or negative size: {monitor!r}" raise ScreenShotError(msg) - with lock: + with self._lock: screenshot = self._grab_impl(monitor) if self.with_cursor and (cursor := self._cursor_impl()): return self._merge(screenshot, cursor) @@ -190,11 +218,10 @@ def monitors(self) -> Monitors: - ``width``: the width - ``height``: the height """ - if not self._monitors: - with lock: + with self._lock: + if not self._monitors: self._monitors_impl() - - return self._monitors + return self._monitors def save( self, diff --git a/src/mss/darwin.py b/src/mss/darwin.py index 7d92d9b..8f95e6d 100644 --- a/src/mss/darwin.py +++ b/src/mss/darwin.py @@ -120,6 +120,7 @@ class MSS(MSSBase): def __init__(self, /, **kwargs: Any) -> None: super().__init__(**kwargs) + # max_displays is only used by _monitors_impl, while the lock is held. #: Maximum number of displays to handle. self.max_displays = kwargs.get("max_displays", 32) diff --git a/src/mss/linux/xlib.py b/src/mss/linux/xlib.py index 2419d59..26708fe 100644 --- a/src/mss/linux/xlib.py +++ b/src/mss/linux/xlib.py @@ -32,10 +32,10 @@ create_string_buffer, ) from ctypes.util import find_library -from threading import current_thread, local +from threading import Lock, current_thread, local from typing import TYPE_CHECKING, Any -from mss.base import MSSBase, lock +from mss.base import MSSBase from mss.exception import ScreenShotError if TYPE_CHECKING: # pragma: nocover @@ -45,6 +45,11 @@ __all__ = ("MSS",) +# Global lock protecting access to Xlib calls. +# A per-object lock must not be acquired while this is held. It is safe to acquire this global lock while a +# per-object lock is currently held. +_lock = Lock() + X_FIRST_EXTENSION_OPCODE = 128 PLAINMASK = 0x00FFFFFF ZPIXMAP = 2 @@ -391,6 +396,13 @@ class MSS(MSSBase): taken from the environment variable :envvar:`DISPLAY`. :type display: str | bytes | None + .. danger:: + The Xlib backend does not have the same multithreading + guarantees as the rest of MSS. Specifically, the object may + only be used on the thread in which it was created. + Additionally, while rare, using multiple MSS objects in + different threads simultaneously may still cause problems. + .. seealso:: :py:class:`mss.base.MSSBase` Lists other parameters. @@ -444,26 +456,27 @@ def __init__(self, /, **kwargs: Any) -> None: self._set_cfunctions() - # Install the error handler to prevent interpreter crashes: any error will raise a ScreenShotError exception - self._handles.original_error_handler = self.xlib.XSetErrorHandler(_error_handler) + with _lock: + # Install the error handler to prevent interpreter crashes: any error will raise a ScreenShotError + # exception + self._handles.original_error_handler = self.xlib.XSetErrorHandler(_error_handler) - self._handles.display = self.xlib.XOpenDisplay(display) - if not self._handles.display: - msg = f"Unable to open display: {display!r}." - raise ScreenShotError(msg) + self._handles.display = self.xlib.XOpenDisplay(display) + if not self._handles.display: + msg = f"Unable to open display: {display!r}." + raise ScreenShotError(msg) + + self._handles.drawable = self._handles.root = self.xlib.XDefaultRootWindow(self._handles.display) if not self._is_extension_enabled("RANDR"): msg = "Xrandr not enabled." raise ScreenShotError(msg) - self._handles.drawable = self._handles.root = self.xlib.XDefaultRootWindow(self._handles.display) - def _close_impl(self) -> None: # Clean-up if self._handles.display: - # We don't grab the lock, since MSSBase.close is holding - # it for us. - self.xlib.XCloseDisplay(self._handles.display) + with _lock: + self.xlib.XCloseDisplay(self._handles.display) self._handles.display = None self._handles.drawable = None self._handles.root = None @@ -475,7 +488,8 @@ def _close_impl(self) -> None: # Interesting technical stuff can be found here: # https://core.tcl-lang.org/tk/file?name=generic/tkError.c&ci=a527ef995862cb50 # https://github.com/tcltk/tk/blob/b9cdafd83fe77499ff47fa373ce037aff3ae286a/generic/tkError.c - self.xlib.XSetErrorHandler(self._handles.original_error_handler) + with _lock: + self.xlib.XSetErrorHandler(self._handles.original_error_handler) self._handles.original_error_handler = None # Also empty the error dict @@ -488,7 +502,7 @@ def _is_extension_enabled(self, name: str, /) -> bool: first_error_return = c_int() try: - with lock: + with _lock: self.xlib.XQueryExtension( self._handles.display, name.encode("latin1"), @@ -519,59 +533,62 @@ def _monitors_impl(self) -> None: int_ = int xrandr = self.xrandr - xrandr_major = c_int(0) - xrandr_minor = c_int(0) - xrandr.XRRQueryVersion(display, xrandr_major, xrandr_minor) - - # All monitors - gwa = XWindowAttributes() - self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa)) - self._monitors.append( - {"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)}, - ) - - # Each monitor - # A simple benchmark calling 10 times those 2 functions: - # XRRGetScreenResources(): 0.1755971429956844 s - # XRRGetScreenResourcesCurrent(): 0.0039125580078689 s - # The second is faster by a factor of 44! So try to use it first. - # It doesn't query the monitors for updated information, but it does require the server to support - # RANDR 1.3. We also make sure the client supports 1.3, by checking for the presence of the function. - if hasattr(xrandr, "XRRGetScreenResourcesCurrent") and (xrandr_major.value, xrandr_minor.value) >= (1, 3): - mon = xrandr.XRRGetScreenResourcesCurrent(display, self._handles.drawable).contents - else: - mon = xrandr.XRRGetScreenResources(display, self._handles.drawable).contents - - crtcs = mon.crtcs - for idx in range(mon.ncrtc): - crtc = xrandr.XRRGetCrtcInfo(display, mon, crtcs[idx]).contents - if crtc.noutput == 0: - xrandr.XRRFreeCrtcInfo(crtc) - continue + with _lock: + xrandr_major = c_int(0) + xrandr_minor = c_int(0) + xrandr.XRRQueryVersion(display, xrandr_major, xrandr_minor) + # All monitors + gwa = XWindowAttributes() + self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa)) self._monitors.append( - { - "left": int_(crtc.x), - "top": int_(crtc.y), - "width": int_(crtc.width), - "height": int_(crtc.height), - }, + {"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)}, ) - xrandr.XRRFreeCrtcInfo(crtc) - xrandr.XRRFreeScreenResources(mon) + + # Each monitor + # A simple benchmark calling 10 times those 2 functions: + # XRRGetScreenResources(): 0.1755971429956844 s + # XRRGetScreenResourcesCurrent(): 0.0039125580078689 s + # The second is faster by a factor of 44! So try to use it first. + # It doesn't query the monitors for updated information, but it does require the server to support RANDR + # 1.3. We also make sure the client supports 1.3, by checking for the presence of the function. + if hasattr(xrandr, "XRRGetScreenResourcesCurrent") and (xrandr_major.value, xrandr_minor.value) >= (1, 3): + mon = xrandr.XRRGetScreenResourcesCurrent(display, self._handles.drawable).contents + else: + mon = xrandr.XRRGetScreenResources(display, self._handles.drawable).contents + + crtcs = mon.crtcs + for idx in range(mon.ncrtc): + crtc = xrandr.XRRGetCrtcInfo(display, mon, crtcs[idx]).contents + if crtc.noutput == 0: + xrandr.XRRFreeCrtcInfo(crtc) + continue + + self._monitors.append( + { + "left": int_(crtc.x), + "top": int_(crtc.y), + "width": int_(crtc.width), + "height": int_(crtc.height), + }, + ) + xrandr.XRRFreeCrtcInfo(crtc) + xrandr.XRRFreeScreenResources(mon) def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: """Retrieve all pixels from a monitor. Pixels have to be RGB.""" - ximage = self.xlib.XGetImage( - self._handles.display, - self._handles.drawable, - monitor["left"], - monitor["top"], - monitor["width"], - monitor["height"], - PLAINMASK, - ZPIXMAP, - ) + + with _lock: + ximage = self.xlib.XGetImage( + self._handles.display, + self._handles.drawable, + monitor["left"], + monitor["top"], + monitor["width"], + monitor["height"], + PLAINMASK, + ZPIXMAP, + ) try: bits_per_pixel = ximage.contents.bits_per_pixel @@ -586,14 +603,16 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: data = bytearray(raw_data.contents) finally: # Free - self.xlib.XDestroyImage(ximage) + with _lock: + self.xlib.XDestroyImage(ximage) return self.cls_image(data, monitor) def _cursor_impl(self) -> ScreenShot: """Retrieve all cursor data. Pixels have to be RGB.""" # Read data of cursor/mouse-pointer - ximage = self.xfixes.XFixesGetCursorImage(self._handles.display) + with _lock: + ximage = self.xfixes.XFixesGetCursorImage(self._handles.display) if not (ximage and ximage.contents): msg = "Cannot read XFixesGetCursorImage()" raise ScreenShotError(msg) diff --git a/src/mss/windows.py b/src/mss/windows.py index 42e4921..df0477a 100644 --- a/src/mss/windows.py +++ b/src/mss/windows.py @@ -26,7 +26,6 @@ UINT, WORD, ) -from threading import local from typing import TYPE_CHECKING, Any, Callable from mss.base import MSSBase @@ -135,22 +134,23 @@ class MSS(MSSBase): Lists constructor parameters. """ - __slots__ = {"_handles", "gdi32", "user32"} + __slots__ = {"_bmi", "_bmp", "_data", "_memdc", "_region_width_height", "_srcdc", "gdi32", "user32"} def __init__(self, /, **kwargs: Any) -> None: super().__init__(**kwargs) + # user32 and gdi32 should not be changed after initialization. self.user32 = ctypes.WinDLL("user32", use_last_error=True) self.gdi32 = ctypes.WinDLL("gdi32", use_last_error=True) self._set_cfunctions() self._set_dpi_awareness() - # Available thread-specific variables - self._handles = local() - self._handles.region_width_height = None - self._handles.bmp = None - self._handles.srcdc = self.user32.GetWindowDC(0) - self._handles.memdc = self.gdi32.CreateCompatibleDC(self._handles.srcdc) + # Available instance-specific variables + self._region_width_height: tuple[int, int] | None = None + self._bmp: HBITMAP | None = None + self._srcdc = self.user32.GetWindowDC(0) + self._memdc = self.gdi32.CreateCompatibleDC(self._srcdc) + self._data: ctypes.Array[ctypes.c_char] | None = None bmi = BITMAPINFO() bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) @@ -163,21 +163,21 @@ def __init__(self, /, **kwargs: Any) -> None: bmi.bmiHeader.biYPelsPerMeter = 0 # Unspecified bmi.bmiHeader.biClrUsed = 0 # See grab.__doc__ [3] bmi.bmiHeader.biClrImportant = 0 # See grab.__doc__ [3] - self._handles.bmi = bmi + self._bmi = bmi def _close_impl(self) -> None: # Clean-up - if self._handles.bmp: - self.gdi32.DeleteObject(self._handles.bmp) - self._handles.bmp = None + if self._bmp: + self.gdi32.DeleteObject(self._bmp) + self._bmp = None - if self._handles.memdc: - self.gdi32.DeleteDC(self._handles.memdc) - self._handles.memdc = None + if self._memdc: + self.gdi32.DeleteDC(self._memdc) + self._memdc = None - if self._handles.srcdc: - self.user32.ReleaseDC(0, self._handles.srcdc) - self._handles.srcdc = None + if self._srcdc: + self.user32.ReleaseDC(0, self._srcdc) + self._srcdc = None def _set_cfunctions(self) -> None: """Set all ctypes functions and attach them to attributes.""" @@ -268,33 +268,32 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: retrieved by gdi32.GetDIBits() as a sequence of RGB values. Thanks to http://stackoverflow.com/a/3688682 """ - srcdc, memdc = self._handles.srcdc, self._handles.memdc + srcdc, memdc = self._srcdc, self._memdc gdi = self.gdi32 width, height = monitor["width"], monitor["height"] - if self._handles.region_width_height != (width, height): - self._handles.region_width_height = (width, height) - self._handles.bmi.bmiHeader.biWidth = width - self._handles.bmi.bmiHeader.biHeight = -height # Why minus? See [1] - self._handles.data = ctypes.create_string_buffer(width * height * 4) # [2] - if self._handles.bmp: - gdi.DeleteObject(self._handles.bmp) + if self._region_width_height != (width, height): + self._region_width_height = (width, height) + self._bmi.bmiHeader.biWidth = width + self._bmi.bmiHeader.biHeight = -height # Why minus? See [1] + self._data = ctypes.create_string_buffer(width * height * 4) # [2] + if self._bmp: + gdi.DeleteObject(self._bmp) # Set to None to prevent another DeleteObject in case CreateCompatibleBitmap raises an exception. - self._handles.bmp = None - self._handles.bmp = gdi.CreateCompatibleBitmap(srcdc, width, height) - gdi.SelectObject(memdc, self._handles.bmp) + self._bmp = None + self._bmp = gdi.CreateCompatibleBitmap(srcdc, width, height) + gdi.SelectObject(memdc, self._bmp) gdi.BitBlt(memdc, 0, 0, width, height, srcdc, monitor["left"], monitor["top"], SRCCOPY | CAPTUREBLT) - scanlines_copied = gdi.GetDIBits( - memdc, self._handles.bmp, 0, height, self._handles.data, self._handles.bmi, DIB_RGB_COLORS - ) + assert self._data is not None # noqa: S101 for type checker + scanlines_copied = gdi.GetDIBits(memdc, self._bmp, 0, height, self._data, self._bmi, DIB_RGB_COLORS) if scanlines_copied != height: # If the result was 0 (failure), an exception would have been raised by _errcheck. This is just a sanity # clause. msg = f"gdi32.GetDIBits() failed: only {scanlines_copied} scanlines copied instead of {height}" raise ScreenShotError(msg) - return self.cls_image(bytearray(self._handles.data), monitor) + return self.cls_image(bytearray(self._data), monitor) def _cursor_impl(self) -> ScreenShot | None: """Retrieve all cursor data. Pixels have to be RGB.""" diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py index 7d229dc..26fe1f3 100644 --- a/src/tests/test_implementation.py +++ b/src/tests/test_implementation.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: # pragma: nocover from collections.abc import Callable + from typing import Any from mss.models import Monitor @@ -299,27 +300,45 @@ def test_grab_with_tuple_percents(mss_impl: Callable[..., MSSBase]) -> None: assert im.rgb == im2.rgb -def test_thread_safety(backend: str) -> None: - """Regression test for issue #169.""" +class TestThreadSafety: + def run_test(self, do_grab: Callable[[], Any]) -> None: + def record() -> None: + """Record for one second.""" + start_time = time.time() + while time.time() - start_time < 1: + do_grab() - def record(check: dict) -> None: - """Record for one second.""" - start_time = time.time() - while time.time() - start_time < 1: - with mss.mss(backend=backend) as sct: - sct.grab(sct.monitors[1]) + checkpoint[threading.current_thread()] = True + + checkpoint: dict = {} + t1 = threading.Thread(target=record) + t2 = threading.Thread(target=record) + + t1.start() + time.sleep(0.5) + t2.start() - check[threading.current_thread()] = True + t1.join() + t2.join() - checkpoint: dict = {} - t1 = threading.Thread(target=record, args=(checkpoint,)) - t2 = threading.Thread(target=record, args=(checkpoint,)) + assert len(checkpoint) == 2 + + def test_issue_169(self, backend: str) -> None: + """Regression test for issue #169.""" + + def do_grab() -> None: + with mss.mss(backend=backend) as sct: + sct.grab(sct.monitors[1]) - t1.start() - time.sleep(0.5) - t2.start() + self.run_test(do_grab) - t1.join() - t2.join() + def test_same_object_multiple_threads(self, backend: str) -> None: + """Ensure that the same MSS object can be used by multiple threads. - assert len(checkpoint) == 2 + This also implicitly tests that it can be used on a thread + different than the one that created it. + """ + if backend == "xlib": + pytest.skip("The xlib backend does not support this ability") + with mss.mss(backend=backend) as sct: + self.run_test(lambda: sct.grab(sct.monitors[1])) diff --git a/src/tests/test_windows.py b/src/tests/test_windows.py index 1e5763b..fca8a5e 100644 --- a/src/tests/test_windows.py +++ b/src/tests/test_windows.py @@ -35,18 +35,18 @@ def test_region_caching() -> None: # Grab the area 1 region1 = {"top": 0, "left": 0, "width": 200, "height": 200} sct.grab(region1) - bmp1 = id(sct._handles.bmp) + bmp1 = id(sct._bmp) # Grab the area 2, the cached BMP is used # Same sizes but different positions region2 = {"top": 200, "left": 200, "width": 200, "height": 200} sct.grab(region2) - bmp2 = id(sct._handles.bmp) + bmp2 = id(sct._bmp) assert bmp1 == bmp2 # Grab the area 2 again, the cached BMP is used sct.grab(region2) - assert bmp2 == id(sct._handles.bmp) + assert bmp2 == id(sct._bmp) def test_region_not_caching() -> None: @@ -60,14 +60,14 @@ def test_region_not_caching() -> None: region1 = {"top": 0, "left": 0, "width": 100, "height": 100} region2 = {"top": 0, "left": 0, "width": 50, "height": 1} grab1.grab(region1) - bmp1 = id(grab1._handles.bmp) + bmp1 = id(grab1._bmp) grab2.grab(region2) - bmp2 = id(grab2._handles.bmp) + bmp2 = id(grab2._bmp) assert bmp1 != bmp2 # Grab the area 1, is not bad cached BMP previous grab the area 2 grab1.grab(region1) - bmp1 = id(grab1._handles.bmp) + bmp1 = id(grab1._bmp) assert bmp1 != bmp2