From 605438fdc094d77c3e8071d906f4baa99456fd98 Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Mon, 30 Mar 2026 17:35:16 -0700 Subject: [PATCH 1/9] Move to a strategy implementation design. See also BoboTiG/python-mss issue #486. The user will always work with a single class: mss.MSS. Differences in implementation, such as platform or capture strategy, are hidden in an internal implementation object held by the mss.MSS object. This allows us to change the implementation, with arbitrary class hierarchies, without worrying about preserving compatibility with internal class names. This deprecates the existing `mss` factory function, although it can easily be kept for as long as needed to give users time to adapt. It also deprecates the existing `mss.{platform}.MSS` types. These are exposed to the user, so somebody calling `mss.{platform}.MSS()` in 10.x can still reasonably expect to get a `mss.{platform}.MSS` object back. However, in 11.0, we can remove the type entirely, and either remove those symbols, or make them deprecated aliases for `mss.MSS`. Where possible, deprecated functionality emits a `DeprecationWarning`. However, note that these are ignored by default, unless triggered by code in `__main__`. Many of the API docs are removed, since this change removes much of the API surface. However, they are still in available for backwards-compatibility. This change adds tests for everything that was in the 10.1 docs, examples, etc, at least at a basic level: for instance, it tests that `mss.linux.MSS` still works as both a constructor and a type (for `isinstance`), and that `mss.linux.ZPIXMAP` still exists (it was listed in the 10.1 docs). The existing code, tests, and docs are changed to use `mss.MSS`. --- README.md | 4 +- demos/cat-detector.py | 2 +- demos/tinytv-stream-simple.py | 2 +- demos/tinytv-stream.py | 2 +- demos/video-capture-simple.py | 2 +- demos/video-capture.py | 4 +- docs/source/api.rst | 60 ---- docs/source/conf.py | 5 +- docs/source/examples.rst | 4 +- docs/source/examples/callback.py | 2 +- docs/source/examples/custom_cls_image.py | 2 +- docs/source/examples/fps.py | 2 +- docs/source/examples/fps_multiprocessing.py | 2 +- docs/source/examples/from_pil_tuple.py | 2 +- docs/source/examples/linux_display_keyword.py | 2 +- docs/source/examples/linux_xshm_backend.py | 4 +- docs/source/examples/opencv_numpy.py | 2 +- docs/source/examples/part_of_screen.py | 2 +- .../examples/part_of_screen_monitor_2.py | 2 +- docs/source/examples/pil.py | 2 +- docs/source/examples/pil_pixels.py | 2 +- docs/source/index.rst | 4 +- docs/source/usage.rst | 62 ++-- src/mss/__init__.py | 3 +- src/mss/__main__.py | 5 +- src/mss/base.py | 264 +++++++++++++----- src/mss/darwin.py | 65 +++-- src/mss/factory.py | 53 +--- src/mss/linux/__init__.py | 74 +++-- src/mss/linux/base.py | 75 ++--- src/mss/linux/xgetimage.py | 11 +- src/mss/linux/xlib.py | 51 ++-- src/mss/linux/xshmgetimage.py | 27 +- src/mss/windows.py | 59 ++-- src/tests/bench_bgra2rgb.py | 2 +- src/tests/bench_general.py | 12 +- src/tests/bench_grab_windows.py | 14 +- src/tests/conftest.py | 7 +- src/tests/test_cls_image.py | 4 +- src/tests/test_compat_10_1.py | 164 +++++++++++ src/tests/test_compat_exports.py | 19 ++ src/tests/test_compat_linux_api.py | 30 ++ src/tests/test_find_monitors.py | 10 +- src/tests/test_get_pixels.py | 6 +- src/tests/test_gnu_linux.py | 119 ++++---- src/tests/test_implementation.py | 43 ++- src/tests/test_issue_220.py | 2 +- src/tests/test_leaks.py | 16 +- src/tests/test_macos.py | 14 +- src/tests/test_primary_monitor.py | 6 +- src/tests/test_save.py | 16 +- src/tests/test_setup.py | 10 +- src/tests/test_tools.py | 4 +- src/tests/test_windows.py | 31 +- src/tests/test_xcb.py | 8 +- src/tests/third_party/test_numpy.py | 4 +- src/tests/third_party/test_pil.py | 8 +- 57 files changed, 887 insertions(+), 526 deletions(-) create mode 100644 src/tests/test_compat_10_1.py create mode 100644 src/tests/test_compat_exports.py create mode 100644 src/tests/test_compat_linux_api.py diff --git a/README.md b/README.md index ba7031a8..99cb10b8 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ > [![Patreon](https://img.shields.io/badge/Patreon-F96854?style=for-the-badge&logo=patreon&logoColor=white)](https://www.patreon.com/mschoentgen) ```python -from mss import mss +from mss import MSS # The simplest use, save a screenshot of the 1st monitor -with mss() as sct: +with MSS() as sct: sct.shot() ``` diff --git a/demos/cat-detector.py b/demos/cat-detector.py index c8ff1175..596dd997 100755 --- a/demos/cat-detector.py +++ b/demos/cat-detector.py @@ -240,7 +240,7 @@ def main() -> None: model_labels = weights.meta["categories"] cat_label = model_labels.index("cat") - with mss.mss() as sct: + with mss.MSS() as sct: monitor = sct.monitors[1] # Compute the minimum size, in square pixels, that we'll consider reliable. diff --git a/demos/tinytv-stream-simple.py b/demos/tinytv-stream-simple.py index c0dc1c75..1046a5d3 100755 --- a/demos/tinytv-stream-simple.py +++ b/demos/tinytv-stream-simple.py @@ -79,7 +79,7 @@ def main() -> None: # Note that we use the same MSS object the whole time. We don't try to keep creating a new MSS object each # time we take a new screenshot. That's because the MSS object has a lot of stuff that it sets up and # remembers, and creating a new MSS object each time would mean that it has to repeat that setup constantly. - with mss.mss() as sct: + with mss.MSS() as sct: # It's time to get the monitor that we're going to capture. In this demo, we just capture the first # monitor. (We could also use monitors[0] for all the monitors combined.) monitor = sct.monitors[1] diff --git a/demos/tinytv-stream.py b/demos/tinytv-stream.py index a3993892..351eebfd 100755 --- a/demos/tinytv-stream.py +++ b/demos/tinytv-stream.py @@ -342,7 +342,7 @@ def capture_image( 'width', 'height'. :yields: PIL Image objects from the captured monitor. """ - with mss.mss() as sct: + with mss.MSS() as sct: rect = capture_area if capture_area is not None else sct.monitors[monitor] LOGGER.debug("Capture area: %i,%i, %ix%i", rect["left"], rect["top"], rect["width"], rect["height"]) diff --git a/demos/video-capture-simple.py b/demos/video-capture-simple.py index 1e7f7f94..7bfc4a05 100755 --- a/demos/video-capture-simple.py +++ b/demos/video-capture-simple.py @@ -68,7 +68,7 @@ def main() -> None: # If we don't enable PyAV's own logging, a lot of important error messages from libav won't be shown. av.logging.set_level(av.logging.VERBOSE) - with mss.mss() as sct: + with mss.MSS() as sct: monitor = sct.monitors[1] # Because of how H.264 video stores color information, libx264 requires the video size to be a multiple of diff --git a/demos/video-capture.py b/demos/video-capture.py index cd7b8ac8..cc89ca99 100755 --- a/demos/video-capture.py +++ b/demos/video-capture.py @@ -164,7 +164,7 @@ def video_capture( fps: int, - sct: mss.base.MSSBase, + sct: mss.MSS, monitor: mss.models.Monitor, shutdown_requested: Event, ) -> Generator[tuple[mss.screenshot.ScreenShot, float], None, None]: @@ -434,7 +434,7 @@ def main() -> None: duration_secs = args.duration_secs region_crop_to_multiple_of_two = args.region_crop_to_multiple_of_two - with mss.mss() as sct: + with mss.MSS() as sct: if args.region: left, top, right, bottom = args.region monitor = { diff --git a/docs/source/api.rst b/docs/source/api.rst index a566783c..5d99fab7 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -7,67 +7,7 @@ Core Package .. automodule:: mss -Screenshot Objects -================== - -.. automodule:: mss.screenshot - -Base Classes -============ - -.. automodule:: mss.base - -Tools -===== - -.. automodule:: mss.tools - -Exceptions -========== - -.. automodule:: mss.exception - Data Models =========== .. automodule:: mss.models - -Platform Backends -================= - -macOS Backend -------------- - -.. automodule:: mss.darwin - -GNU/Linux Dispatcher --------------------- - -.. automodule:: mss.linux - -GNU/Linux Xlib Backend ----------------------- - -.. automodule:: mss.linux.xlib - -GNU/Linux XCB Base ------------------- - -.. automodule:: mss.linux.base - -GNU/Linux XCB XGetImage Backend -------------------------------- - -.. automodule:: mss.linux.xgetimage - -GNU/Linux XCB XShmGetImage Backend ----------------------------------- - -.. automodule:: mss.linux.xshmgetimage - -Windows Backend ---------------- - -.. automodule:: mss.windows - - diff --git a/docs/source/conf.py b/docs/source/conf.py index e95d58b6..11f3627c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -9,11 +9,12 @@ import ctypes # Monkey-patch PROT_READ into mmap if missing (Windows), so that we can -# import mss.linux.xshmgetimage while building the documentation. +# import the Linux shared-memory backend implementation while building the +# documentation. import mmap if not hasattr(mmap, "PROT_READ"): - mmap.PROT_READ = 1 # type:ignore[attr-defined] + mmap.PROT_READ = 1 import mss diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 09e3c2f2..e10c6bb3 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -83,7 +83,7 @@ Get PNG bytes, no file output You can get the bytes of the PNG image: :: - with mss.mss() as sct: + with mss.MSS() as sct: # The monitor or screen part to capture monitor = sct.monitors[1] # or a region @@ -203,7 +203,7 @@ Different possibilities to convert raw BGRA values to RGB:: return Image.frombytes('RGB', im.size, im.bgra, 'raw', 'BGRX').tobytes() - with mss.mss() as sct: + with mss.MSS() as sct: im = sct.grab(sct.monitors[1]) rgb = pil_frombytes(im) ... diff --git a/docs/source/examples/callback.py b/docs/source/examples/callback.py index 5a93d122..6355766a 100644 --- a/docs/source/examples/callback.py +++ b/docs/source/examples/callback.py @@ -18,6 +18,6 @@ def on_exists(fname: str) -> None: file.rename(newfile) -with mss.mss() as sct: +with mss.MSS() as sct: filename = sct.shot(output="mon-{mon}.png", callback=on_exists) print(filename) diff --git a/docs/source/examples/custom_cls_image.py b/docs/source/examples/custom_cls_image.py index 2a1f8102..2c04a150 100644 --- a/docs/source/examples/custom_cls_image.py +++ b/docs/source/examples/custom_cls_image.py @@ -22,7 +22,7 @@ def __init__(self, data: bytearray, monitor: Monitor, **_: Any) -> None: self.monitor = monitor -with mss.mss() as sct: +with mss.MSS() as sct: sct.cls_image = SimpleScreenShot image = sct.grab(sct.monitors[1]) # ... diff --git a/docs/source/examples/fps.py b/docs/source/examples/fps.py index f9e76134..dde93cd8 100644 --- a/docs/source/examples/fps.py +++ b/docs/source/examples/fps.py @@ -40,7 +40,7 @@ def screen_record_efficient() -> int: title = "[MSS] FPS benchmark" fps = 0 - sct = mss.mss() + sct = mss.MSS() last_time = time.time() while time.time() - last_time < 1: diff --git a/docs/source/examples/fps_multiprocessing.py b/docs/source/examples/fps_multiprocessing.py index c4a2a38a..ea28dad7 100644 --- a/docs/source/examples/fps_multiprocessing.py +++ b/docs/source/examples/fps_multiprocessing.py @@ -14,7 +14,7 @@ def grab(queue: Queue) -> None: rect = {"top": 0, "left": 0, "width": 600, "height": 800} - with mss.mss() as sct: + with mss.MSS() as sct: for _ in range(1_000): queue.put(sct.grab(rect)) diff --git a/docs/source/examples/from_pil_tuple.py b/docs/source/examples/from_pil_tuple.py index 3c5297b9..aa056109 100644 --- a/docs/source/examples/from_pil_tuple.py +++ b/docs/source/examples/from_pil_tuple.py @@ -7,7 +7,7 @@ import mss import mss.tools -with mss.mss() as sct: +with mss.MSS() as sct: # Use the 1st monitor monitor = sct.monitors[1] diff --git a/docs/source/examples/linux_display_keyword.py b/docs/source/examples/linux_display_keyword.py index bb6c3950..6e68c799 100644 --- a/docs/source/examples/linux_display_keyword.py +++ b/docs/source/examples/linux_display_keyword.py @@ -6,6 +6,6 @@ import mss -with mss.mss(display=":0.0") as sct: +with mss.MSS(display=":0.0") as sct: for filename in sct.save(): print(filename) diff --git a/docs/source/examples/linux_xshm_backend.py b/docs/source/examples/linux_xshm_backend.py index 3d2f22bf..66ad0156 100644 --- a/docs/source/examples/linux_xshm_backend.py +++ b/docs/source/examples/linux_xshm_backend.py @@ -4,9 +4,9 @@ Select the XShmGetImage backend explicitly and inspect its status. """ -from mss.linux.xshmgetimage import MSS as mss +from mss import MSS -with mss() as sct: +with MSS(backend="xshmgetimage") as sct: screenshot = sct.grab(sct.monitors[1]) print(f"Captured screenshot dimensions: {screenshot.size.width}x{screenshot.size.height}") diff --git a/docs/source/examples/opencv_numpy.py b/docs/source/examples/opencv_numpy.py index 9275de2b..1eb51fd5 100644 --- a/docs/source/examples/opencv_numpy.py +++ b/docs/source/examples/opencv_numpy.py @@ -11,7 +11,7 @@ import mss -with mss.mss() as sct: +with mss.MSS() as sct: # Part of the screen to capture monitor = {"top": 40, "left": 0, "width": 800, "height": 640} diff --git a/docs/source/examples/part_of_screen.py b/docs/source/examples/part_of_screen.py index 5ef341dc..2cc29841 100644 --- a/docs/source/examples/part_of_screen.py +++ b/docs/source/examples/part_of_screen.py @@ -7,7 +7,7 @@ import mss import mss.tools -with mss.mss() as sct: +with mss.MSS() as sct: # The screen part to capture monitor = {"top": 160, "left": 160, "width": 160, "height": 135} output = "sct-{top}x{left}_{width}x{height}.png".format(**monitor) diff --git a/docs/source/examples/part_of_screen_monitor_2.py b/docs/source/examples/part_of_screen_monitor_2.py index 6099f58a..082a56f6 100644 --- a/docs/source/examples/part_of_screen_monitor_2.py +++ b/docs/source/examples/part_of_screen_monitor_2.py @@ -7,7 +7,7 @@ import mss import mss.tools -with mss.mss() as sct: +with mss.MSS() as sct: # Get information of monitor 2 monitor_number = 2 mon = sct.monitors[monitor_number] diff --git a/docs/source/examples/pil.py b/docs/source/examples/pil.py index 03ff778c..dfb706b4 100644 --- a/docs/source/examples/pil.py +++ b/docs/source/examples/pil.py @@ -8,7 +8,7 @@ import mss -with mss.mss() as sct: +with mss.MSS() as sct: # Get rid of the first, as it represents the "All in One" monitor: for num, monitor in enumerate(sct.monitors[1:], 1): # Get raw pixels from the screen diff --git a/docs/source/examples/pil_pixels.py b/docs/source/examples/pil_pixels.py index d1264bc6..fdb1f673 100644 --- a/docs/source/examples/pil_pixels.py +++ b/docs/source/examples/pil_pixels.py @@ -8,7 +8,7 @@ import mss -with mss.mss() as sct: +with mss.MSS() as sct: # Get a screenshot of the 1st monitor sct_img = sct.grab(sct.monitors[1]) diff --git a/docs/source/index.rst b/docs/source/index.rst index 78bf095d..84e89c15 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,10 +11,10 @@ Welcome to Python MSS's documentation! .. code-block:: python - from mss import mss + from mss import MSS # The simplest use, save a screenshot of the 1st monitor - with mss() as sct: + with MSS() as sct: sct.shot() diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 087fad9b..b01bfee9 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -5,34 +5,33 @@ Usage Import ====== -MSS can be used as simply as:: +MSS can be used simply as:: - from mss import mss + from mss import MSS -Or import the good one based on your operating system:: + with MSS() as sct: + # ... - # GNU/Linux - from mss.linux import MSS as mss +For compatibility with existing code, :py:func:`mss.mss` is still available in +10.2, but deprecated:: - # macOS - from mss.darwin import MSS as mss + import mss - # Microsoft Windows - from mss.windows import MSS as mss - -On GNU/Linux you can also import a specific backend (see :ref:`backends`) -directly when you need a particular implementation, for example:: + with mss.mss() as sct: # Deprecated in 10.2 + # ... - from mss.linux.xshmgetimage import MSS as mss +For compatibility with existing code, platform-specific class names are also +still available in 10.2:: + # GNU/Linux + from mss.linux import MSS -Instance -======== + # macOS + from mss.darwin import MSS -So the module can be used as simply as:: + # Microsoft Windows + from mss.windows import MSS - with mss() as sct: - # ... Intensive Use ============= @@ -42,12 +41,12 @@ If you plan to integrate MSS inside your own module or software, pay attention t This is a bad usage:: for _ in range(100): - with mss() as sct: + with MSS() as sct: sct.shot() This is a much better usage, memory efficient:: - with mss() as sct: + with MSS() as sct: for _ in range(100): sct.shot() @@ -60,18 +59,19 @@ 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 +:py:meth:`mss.MSS.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. +:py:meth:`mss.MSS.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. + These guarantees do not apply to the obsolete Xlib backend. 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 @@ -82,19 +82,15 @@ Custom :py:class:`mss.screenshot.ScreenShot` classes (see :ref:`custom_cls_image 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. +Some platforms have multiple ways to take screenshots. In MSS, these are known as *backends*. The :py:class:`mss.MSS` +constructor 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`:: +If you want to choose a particular backend, you can pass the ``backend`` keyword to :py:class:`mss.MSS`:: - with mss(backend="xgetimage") as sct: + with MSS(backend="xgetimage") as sct: ... -Alternatively, you can also directly import the backend you want to use:: - - from mss.linux.xgetimage import MSS as mss - Currently, only the GNU/Linux implementation has multiple backends. These are described in their own section below. @@ -113,7 +109,7 @@ On GNU/Linux, the default display is taken from the :envvar:`DISPLAY` environmen Backends ^^^^^^^^ -The GNU/Linux implementation has multiple backends (see :ref:`backends`), or ways it can take screenshots. The :py:func:`mss.mss` and :py:func:`mss.linux.mss` functions will normally autodetect which one is appropriate, but you can override this if you want. +The GNU/Linux implementation has multiple backends (see :ref:`backends`), or ways it can take screenshots. The :py:class:`mss.MSS` constructor will normally autodetect which one is appropriate, but you can override this if you want. There are three available backends. diff --git a/src/mss/__init__.py b/src/mss/__init__.py index 8ce267ed..ead3c23b 100644 --- a/src/mss/__init__.py +++ b/src/mss/__init__.py @@ -7,6 +7,7 @@ using ctypes. """ +from mss.base import MSS, ScreenShot from mss.exception import ScreenShotError from mss.factory import mss @@ -23,4 +24,4 @@ in supporting documentation or portions thereof, including modifications, that you make. """ -__all__ = ("ScreenShotError", "mss") +__all__ = ("MSS", "ScreenShot", "ScreenShotError", "mss") diff --git a/src/mss/__main__.py b/src/mss/__main__.py index c170aefc..8263d2b2 100644 --- a/src/mss/__main__.py +++ b/src/mss/__main__.py @@ -7,9 +7,8 @@ import sys from argparse import ArgumentError, ArgumentParser -from mss import __version__ +from mss import MSS, __version__ from mss.exception import ScreenShotError -from mss.factory import mss from mss.tools import to_png @@ -91,7 +90,7 @@ def main(*args: str) -> int: kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png" try: - with mss(with_cursor=options.with_cursor, backend=options.backend) as sct: + with MSS(with_cursor=options.with_cursor, backend=options.backend) as sct: if options.coordinates: output = kwargs["output"].format(**kwargs["mon"]) sct_img = sct.grab(kwargs["mon"]) diff --git a/src/mss/base.py b/src/mss/base.py index f4639123..1156c9e4 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -3,7 +3,8 @@ from __future__ import annotations -from abc import ABCMeta, abstractmethod +import platform +from abc import ABC, abstractmethod from datetime import datetime from threading import Lock from typing import TYPE_CHECKING, Any @@ -15,7 +16,8 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator - from mss.models import Monitor, Monitors + from mss.linux.xshmgetimage import ShmStatus + from mss.models import Monitor, Monitors, Size # Prior to 3.11, Python didn't have the Self type. typing_extensions does, but we don't want to depend on it. try: @@ -46,33 +48,136 @@ OPAQUE = 255 -class MSSBase(metaclass=ABCMeta): - """Base class for all Multiple ScreenShots implementations. +__all__ = () + + +class MSSImplementation(ABC): + """Base class for internal platform/backend implementations. + + Only one of these methods will be called at a time; the containing + MSS object will hold a lock during these calls. + """ + + __slots__ = ("with_cursor",) + + with_cursor: bool + + def __init__(self, /, *, with_cursor: bool = False) -> None: + # We put with_cursor on the MSSImplementation because the Xlib + # backend will turn it off if the library isn't installed. + # (It's not a separate library under XCB.) So, we need to let + # the backend mutate it. + + # We should remove this expectation in 11.0. It seems + # unlikely to be practically useful, Xlib is legacy, and just + # complicates things. + self.with_cursor = with_cursor + + @abstractmethod + def cursor(self) -> ScreenShot | None: + """Retrieve all cursor data. Pixels have to be RGB.""" + + @abstractmethod + def grab(self, monitor: Monitor, /) -> bytearray | tuple[bytearray, Size]: + """Retrieve all pixels from a monitor. Pixels have to be RGB. + + If the monitor size is not in pixel units, include a Size in + pixels (see issue #23). + """ + + @abstractmethod + def monitors(self) -> Monitors: + """Return positions of monitors.""" + + def close(self) -> None: # noqa: B027 - intentionally empty + """Clean up. + + This will be called at most once. + + It's not necessary for subclasses to implement this if they + have nothing to clean up. + """ + + @staticmethod + def _cfactory( + attr: Any, + func: str, + argtypes: list[Any], + restype: Any, + /, + errcheck: Callable | None = None, + ) -> None: + """Factory to create a ctypes function and automatically manage errors.""" + meth = getattr(attr, func) + meth.argtypes = argtypes + meth.restype = restype + if errcheck: + meth.errcheck = errcheck + + +def _choose_impl(**kwargs: Any) -> MSSImplementation: + """Return the backend implementation for the current platform. + + Detects the platform we are running on and instantiates the + appropriate internal implementation class. + + .. seealso:: + - :class:`mss.MSS` + - :class:`mss.darwin.MSS` + - :class:`mss.linux.MSS` + - :class:`mss.windows.MSS` + """ + os_ = platform.system().lower() + + if os_ == "darwin": + from mss.darwin import MSSImplDarwin # noqa: PLC0415 + + return MSSImplDarwin(**kwargs) + + if os_ == "linux": + from mss.linux import choose_impl as choose_impl_linux # noqa: PLC0415 + + # Linux has its own factory to choose the backend. + return choose_impl_linux(**kwargs) + + if os_ == "windows": + from mss.windows import MSSImplWindows # noqa: PLC0415 + + return MSSImplWindows(**kwargs) + + msg = f"System {os_!r} not (yet?) implemented." + raise ScreenShotError(msg) + + +# Does this belong here? +class MSS: + """Multiple ScreenShots class :param backend: Backend selector, for platforms with multiple backends. :param compression_level: PNG compression level. :param with_cursor: Include the mouse cursor in screenshots. :param display: X11 display name (GNU/Linux only). + :type display: bytes | str, optional (default :envvar:`$DISPLAY`) :param max_displays: Maximum number of displays to enumerate (macOS only). + :type max_displays: int, optional (default 32) .. versionadded:: 8.0.0 ``compression_level``, ``display``, ``max_displays``, and ``with_cursor`` keyword arguments. """ - __slots__ = {"_closed", "_lock", "_monitors", "cls_image", "compression_level", "with_cursor"} - def __init__( self, /, *, backend: str = "default", compression_level: int = 6, - with_cursor: bool = False, - # Linux only - display: bytes | str | None = None, # noqa: ARG002 - # Mac only - max_displays: int = 32, # noqa: ARG002 + **kwargs: Any, ) -> None: + self._impl: MSSImplementation = _choose_impl( + backend=backend, + **kwargs, + ) + # 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. @@ -81,31 +186,15 @@ def __init__( #: #: .. 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._monitors: Monitors | None = None 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": - msg = 'The only valid backend on this platform is "default".' - raise ScreenShotError(msg) - def __enter__(self) -> Self: """For the cool call `with MSS() as mss:`.""" return self @@ -114,38 +203,6 @@ def __exit__(self, *_: object) -> None: """For the cool call `with MSS() as mss:`.""" self.close() - @abstractmethod - def _cursor_impl(self) -> ScreenShot | None: - """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. - - The object lock will be held when this method is called. - """ - - @abstractmethod - def _monitors_impl(self) -> None: - """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. - def close(self) -> None: """Clean up. @@ -158,13 +215,13 @@ def close(self) -> None: Rather than use :py:meth:`close` explicitly, we recommend you use the MSS object as a context manager:: - with mss.mss() as sct: + with mss.MSS() as sct: ... """ with self._lock: if self._closed: return - self._close_impl() + self._impl.close() self._closed = True def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: @@ -191,8 +248,14 @@ def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: raise ScreenShotError(msg) with self._lock: - screenshot = self._grab_impl(monitor) - if self.with_cursor and (cursor := self._cursor_impl()): + img_data_and_maybe_size = self._impl.grab(monitor) + if isinstance(img_data_and_maybe_size, tuple): + img_data, size = img_data_and_maybe_size + screenshot = self.cls_image(img_data, monitor, size=size) + else: + img_data = img_data_and_maybe_size + screenshot = self.cls_image(img_data, monitor) + if self._impl.with_cursor and (cursor := self._impl.cursor()): return self._merge(screenshot, cursor) return screenshot @@ -220,8 +283,9 @@ def monitors(self) -> Monitors: - ``output``: (optional, Linux only) monitor output name, compatible with xrandr """ with self._lock: - if not self._monitors: - self._monitors_impl() + if self._monitors is None: + self._monitors = self._impl.monitors() + assert self._monitors is not None # noqa: S101 return self._monitors @property @@ -369,3 +433,71 @@ def _cfactory( meth.restype = restype if errcheck: meth.errcheck = errcheck + + # Some backends may expose additional read-only attributes. Those + # are implemented here, as properties. By making them properties, + # instead of using __getattr__, they're also accessible to Sphinx + # and type checkers. + # + # Important: We need to be judicious in what we add here. We + # really don't want these to proliferate. Some, like + # max_displays, should probably be removed in 11.0. with_cursor + # should probably be moved to MSS instead of MSSImplementation (as + # noted there). + # + # The shm_status is mostly a debugging field, and probably should + # be replaced with something different. Ideas include a log + # message, an exception if the user explicitly requested + # xshmgetimage, or a platform-independent message attribute (for + # instance, if Windows has to fall back to GDI). + + @property + def shm_status(self) -> ShmStatus: + """Whether we can use the MIT-SHM extensions for this connection. + + Availability: GNU/Linux, when using the default XShmGetImage backend. + + This will not be ``AVAILABLE`` until at least one capture has succeeded. + It may be set to ``UNAVAILABLE`` sooner. + + .. versionadded:: 10.2.0 + """ + return self._impl.shm_status # type: ignore[attr-defined] + + @property + def shm_fallback_reason(self) -> str | None: + """If MIT-SHM is unavailable, the reason why (for debugging purposes). + + Availability: GNU/Linux, when using the default XShmGetImage backend. + + .. versionadded:: 10.2.0 + """ + return self._impl.shm_fallback_reason # type: ignore[attr-defined] + + @property + def max_displays(self) -> int: + """Maximum number of displays to handle. + + Availability: macOS + + .. versionadded:: 8.0.0 + """ + return self._impl.max_displays # type: ignore[attr-defined] + + @property + def with_cursor(self) -> bool: + """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 cannot be changed after creating the object. + + .. versionadded:: 8.0.0 + """ + return self._impl.with_cursor + + +# TODO(jholveck): #493 Remove compatibility alias after 10.x transition period. +MSSBase = MSS diff --git a/src/mss/darwin.py b/src/mss/darwin.py index a87666ab..8f55ef9b 100644 --- a/src/mss/darwin.py +++ b/src/mss/darwin.py @@ -9,6 +9,7 @@ import ctypes import ctypes.util import sys +import warnings from ctypes import ( POINTER, Structure, @@ -22,14 +23,17 @@ c_void_p, ) from platform import mac_ver -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from mss.base import MSSBase +from mss.base import MSS as _MSS +from mss.base import MSSImplementation from mss.exception import ScreenShotError -from mss.screenshot import ScreenShot, Size +from mss.screenshot import Size if TYPE_CHECKING: - from mss.models import CFunctions, Monitor + from typing import Any + + from mss.models import CFunctions, Monitor, Monitors __all__ = ("IMAGE_OPTIONS", "MSS") @@ -45,6 +49,22 @@ IMAGE_OPTIONS: int = kCGWindowImageBoundsIgnoreFraming | kCGWindowImageShouldBeOpaque | kCGWindowImageNominalResolution +class MSS(_MSS): + """Deprecated macOS compatibility constructor. + + Use :class:`mss.MSS` instead. + """ + + def __init__(self, /, **kwargs: Any) -> None: + # TODO(jholveck): #493 Remove compatibility constructor after 10.x transition period. + warnings.warn( + "mss.darwin.MSS is deprecated and will be removed in 11.0; use mss.MSS instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) + + def cgfloat() -> type[c_double | c_float]: """Get the appropriate value for a float.""" return c_double if sys.maxsize > 2**32 else c_float @@ -102,7 +122,7 @@ def __repr__(self) -> str: } -class MSS(MSSBase): +class MSSImplDarwin(MSSImplementation): """Multiple ScreenShots implementation for macOS. It uses intensively the CoreGraphics library. @@ -111,18 +131,23 @@ class MSS(MSSBase): .. seealso:: - :py:class:`mss.base.MSSBase` + :py:class:`mss.MSS` Lists other parameters. """ __slots__ = {"core", "max_displays"} - def __init__(self, /, **kwargs: Any) -> None: - super().__init__(**kwargs) + def __init__(self, backend: str = "default", max_displays: int = 32, **kwargs: Any) -> None: + kwargs.pop("with_cursor", None) + super().__init__(with_cursor=False, **kwargs) - # max_displays is only used by _monitors_impl, while the lock is held. + if backend != "default": + msg = 'The only valid backend on this platform is "default".' + raise ScreenShotError(msg) + + # max_displays is only used by monitors(), while the lock is held. #: Maximum number of displays to handle. - self.max_displays = kwargs.get("max_displays", 32) + self.max_displays = max_displays self._init_library() self._set_cfunctions() @@ -149,16 +174,18 @@ def _set_cfunctions(self) -> None: for func, (attr, argtypes, restype) in CFUNCTIONS.items(): cfactory(attrs[attr], func, argtypes, restype) - def _monitors_impl(self) -> None: + def monitors(self) -> Monitors: """Get positions of monitors. It will populate self._monitors.""" int_ = int core = self.core + monitors: Monitors = [] + # All monitors # We need to update the value with every single monitor found # using CGRectUnion. Else we will end with infinite values. all_monitors = CGRect() - self._monitors.append({}) + monitors.append({}) # Each monitor display_count = c_uint32(0) @@ -176,7 +203,7 @@ def _monitors_impl(self) -> None: if core.CGDisplayRotation(display) in {90.0, -90.0}: width, height = height, width - self._monitors.append( + monitors.append( { "left": int_(rect.origin.x), "top": int_(rect.origin.y), @@ -189,14 +216,16 @@ def _monitors_impl(self) -> None: all_monitors = core.CGRectUnion(all_monitors, rect) # Set the AiO monitor's values - self._monitors[0] = { + monitors[0] = { "left": int_(all_monitors.origin.x), "top": int_(all_monitors.origin.y), "width": int_(all_monitors.size.width), "height": int_(all_monitors.size.height), } - def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: + return monitors + + def grab(self, monitor: Monitor, /) -> tuple[bytearray, Size]: """Retrieve all pixels from a monitor. Pixels have to be RGB.""" core = self.core rect = CGRect((monitor["left"], monitor["top"]), (monitor["width"], monitor["height"])) @@ -234,8 +263,8 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: core.CFRelease(copy_data) core.CFRelease(image_ref) - return self.cls_image(data, monitor, size=Size(width, height)) + return data, Size(width, height) - def _cursor_impl(self) -> ScreenShot | None: + def cursor(self) -> None: """Retrieve all cursor data. Pixels have to be RGB.""" - return None + return diff --git a/src/mss/factory.py b/src/mss/factory.py index e9bf40a8..91a46516 100644 --- a/src/mss/factory.py +++ b/src/mss/factory.py @@ -1,49 +1,22 @@ # This is part of the MSS Python's module. # Source: https://github.com/BoboTiG/python-mss. -import platform +import warnings from typing import Any -from mss.base import MSSBase -from mss.exception import ScreenShotError +from mss.base import MSS -def mss(**kwargs: Any) -> MSSBase: - """Factory returning a proper MSS class instance. +def mss(**kwargs: Any) -> MSS: + """Create an :class:`mss.MSS` instance for the current platform. - It detects the platform we are running on - and chooses the most adapted mss_class to take - screenshots. - - It then proxies its arguments to the class for - instantiation. - - .. seealso:: - - :class:`mss.darwin.MSS` - - :class:`mss.linux.MSS` - - :class:`mss.windows.MSS` - - :func:`mss.linux.mss` - - :class:`mss.linux.xshmgetimage.MSS` - - :class:`mss.linux.xgetimage.MSS` - - :class:`mss.linux.xlib.MSS` + .. deprecated:: 10.2.0 + Use :class:`mss.MSS` directly. """ - os_ = platform.system().lower() - - if os_ == "darwin": - from mss import darwin # noqa: PLC0415 - - return darwin.MSS(**kwargs) - - if os_ == "linux": - from mss import linux # noqa: PLC0415 - - # Linux has its own factory to choose the backend. - return linux.mss(**kwargs) - - if os_ == "windows": - from mss import windows # noqa: PLC0415 - - return windows.MSS(**kwargs) - - msg = f"System {os_!r} not (yet?) implemented." - raise ScreenShotError(msg) + # TODO(jholveck): #493 Remove compatibility deprecation path once 10.x transition period ends. + warnings.warn( + "mss.mss is deprecated and will be removed in a future release; use mss.MSS instead", + DeprecationWarning, + stacklevel=2, + ) + return MSS(**kwargs) diff --git a/src/mss/linux/__init__.py b/src/mss/linux/__init__.py index 08defd6b..6531433e 100644 --- a/src/mss/linux/__init__.py +++ b/src/mss/linux/__init__.py @@ -1,14 +1,49 @@ """GNU/Linux backend dispatcher for X11 screenshot implementations.""" +import warnings from typing import Any -from mss.base import MSSBase +from mss.base import MSS as _MSS +from mss.base import MSSImplementation from mss.exception import ScreenShotError +# TODO(jholveck): #493 Remove these legacy symbol re-exports after 10.x transition period. +from mss.linux.xlib import ( # noqa: F401 + CFUNCTIONS, + PLAINMASK, + ZPIXMAP, + Display, + XErrorEvent, + XFixesCursorImage, + XImage, + XRRCrtcInfo, + XRRModeInfo, + XRRScreenResources, + XWindowAttributes, +) + +__all__ = ["MSS"] + BACKENDS = ["default", "xlib", "xgetimage", "xshmgetimage"] -def mss(backend: str = "default", **kwargs: Any) -> MSSBase: +class MSS(_MSS): + """Deprecated GNU/Linux compatibility constructor. + + Use :class:`mss.MSS` instead. + """ + + def __init__(self, /, **kwargs: Any) -> None: + # TODO(jholveck): #493 Remove compatibility constructor after 10.x transition period. + warnings.warn( + "mss.linux.MSS is deprecated and will be removed in 11.0; use mss.MSS instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) + + +def choose_impl(backend: str = "default", **kwargs: Any) -> MSSImplementation: """Return a backend-specific MSS implementation for GNU/Linux. Selects and instantiates the appropriate X11 backend based on the @@ -18,14 +53,13 @@ def mss(backend: str = "default", **kwargs: Any) -> MSSBase: - ``"default"`` or ``"xshmgetimage"`` (default): XCB-based backend using XShmGetImage with automatic fallback to XGetImage when MIT-SHM - is unavailable; see :py:class:`mss.linux.xshmgetimage.MSS`. - - ``"xgetimage"``: XCB-based backend using XGetImage; - see :py:class:`mss.linux.xgetimage.MSS`. + is unavailable. + - ``"xgetimage"``: XCB-based backend using XGetImage. - ``"xlib"``: Legacy Xlib-based backend retained for environments - without working XCB libraries; see :py:class:`mss.linux.xlib.MSS`. + without working XCB libraries. .. versionadded:: 10.2.0 Prior to this version, the - :class:`mss.linux.xlib.MSS` implementation was the only available + legacy Xlib implementation was the only available backend. :param display: Optional keyword argument. Specifies an X11 display @@ -36,35 +70,25 @@ def mss(backend: str = "default", **kwargs: Any) -> MSSBase: :returns: An MSS backend implementation. .. versionadded:: 10.2.0 Prior to this version, this didn't exist: - the :func:`mss.linux.MSS` was a class equivalent to the current - :class:`mss.linux.xlib.MSS` implementation. + GNU/Linux had a single implementation selected through + :class:`mss.linux.MSS`. """ backend = backend.lower() if backend == "xlib": - from mss.linux import xlib # noqa: PLC0415 + from mss.linux.xlib import MSSImplXlib # noqa: PLC0415 - return xlib.MSS(**kwargs) + return MSSImplXlib(**kwargs) if backend == "xgetimage": - from mss.linux import xgetimage # noqa: PLC0415 + from mss.linux.xgetimage import MSSImplXGetImage # noqa: PLC0415 # Note that the xshmgetimage backend will automatically fall back to XGetImage calls if XShmGetImage isn't # available. The only reason to use the xgetimage backend is if the user already knows that XShmGetImage # isn't going to be supported. - return xgetimage.MSS(**kwargs) + return MSSImplXGetImage(**kwargs) if backend in {"default", "xshmgetimage"}: - from mss.linux import xshmgetimage # noqa: PLC0415 + from mss.linux.xshmgetimage import MSSImplXShmGetImage # noqa: PLC0415 - return xshmgetimage.MSS(**kwargs) + return MSSImplXShmGetImage(**kwargs) assert backend not in BACKENDS # noqa: S101 msg = f"Backend {backend!r} not (yet?) implemented." raise ScreenShotError(msg) - - -# Alias in upper-case for backward compatibility. This is a supported name in the docs. -def MSS(*args, **kwargs) -> MSSBase: # type: ignore[no-untyped-def] # noqa: N802, ANN002, ANN003 - """Alias for :func:`mss.linux.mss.mss` for backward compatibility. - - .. versionchanged:: 10.2.0 Prior to this version, this was a class. - .. deprecated:: 10.2.0 Use :func:`mss.linux.mss` instead. - """ - return mss(*args, **kwargs) diff --git a/src/mss/linux/base.py b/src/mss/linux/base.py index 76d5ba0c..0bde2bab 100644 --- a/src/mss/linux/base.py +++ b/src/mss/linux/base.py @@ -3,17 +3,19 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlencode -from mss.base import MSSBase +from mss.base import MSSImplementation from mss.exception import ScreenShotError from mss.linux import xcb from mss.linux.xcb import LIB +from mss.screenshot import ScreenShot from mss.tools import parse_edid if TYPE_CHECKING: from ctypes import Array - from mss.models import Monitor - from mss.screenshot import ScreenShot + from mss.models import Monitor, Monitors + +__all__ = () SUPPORTED_DEPTHS = {24, 32} SUPPORTED_BITS_PER_PIXEL = 32 @@ -23,7 +25,7 @@ ALL_PLANES = 0xFFFFFFFF # XCB doesn't define AllPlanes -class MSSXCBBase(MSSBase): +class MSSImplXCBBase(MSSImplementation): """Base class for XCB-based screenshot implementations. Provides common XCB initialization and monitor detection logic that can be @@ -36,14 +38,13 @@ class MSSXCBBase(MSSBase): :type display: str | bytes | None .. seealso:: - :py:class:`mss.base.MSSBase` + :py:class:`mss.MSS` Lists other parameters. """ - def __init__(self, /, **kwargs: Any) -> None: # noqa: PLR0912 + def __init__(self, /, display: str | bytes | None = None, **kwargs: Any) -> None: # noqa: PLR0912 super().__init__(**kwargs) - display = kwargs.get("display", b"") if not display: display = None elif isinstance(display, str): @@ -126,13 +127,13 @@ def __init__(self, /, **kwargs: Any) -> None: # noqa: PLR0912 msg = "Only visuals with BGRx ordering are supported" raise ScreenShotError(msg) - def _close_impl(self) -> None: + def close(self) -> None: """Close the XCB connection.""" if self.conn is not None: xcb.disconnect(self.conn) self.conn = None - def _monitors_impl(self) -> None: + def monitors(self) -> Monitors: """Populate monitor geometry information. Detects and appends monitor rectangles to ``self._monitors``. The first @@ -143,11 +144,13 @@ def _monitors_impl(self) -> None: msg = "Cannot identify monitors while the connection is closed" raise ScreenShotError(msg) - self._append_root_monitor() + monitors = [] + + monitors.append(self._root_monitor()) randr_version = self._randr_get_version() if randr_version is None or randr_version < (1, 2): - return + return monitors # XRandR terminology (very abridged, but enough for this code): # - X screen / framebuffer: the overall drawable area for this root. @@ -165,24 +168,24 @@ def _monitors_impl(self) -> None: edid_atom = self._randr_get_edid_atom() if randr_version >= (1, 5): - self._monitors_from_randr_monitors(primary_output, edid_atom) + monitors += self._monitors_from_randr_monitors(primary_output, edid_atom) else: - self._monitors_from_randr_crtcs(randr_version, primary_output, edid_atom) + monitors += self._monitors_from_randr_crtcs(randr_version, primary_output, edid_atom) + + return monitors - def _append_root_monitor(self) -> None: + def _root_monitor(self) -> Monitor: if self.conn is None: msg = "Cannot identify monitors while the connection is closed" raise ScreenShotError(msg) root_geom = xcb.get_geometry(self.conn, self.root) - self._monitors.append( - { - "left": root_geom.x, - "top": root_geom.y, - "width": root_geom.width, - "height": root_geom.height, - } - ) + return { + "left": root_geom.x, + "top": root_geom.y, + "width": root_geom.width, + "height": root_geom.height, + } def _randr_get_version(self) -> tuple[int, int] | None: if self.conn is None: @@ -289,11 +292,13 @@ def _choose_randr_output( def _monitors_from_randr_monitors( self, primary_output: xcb.RandrOutput | None, edid_atom: xcb.Atom | None, / - ) -> None: + ) -> Monitors: if self.conn is None: msg = "Cannot identify monitors while the connection is closed" raise ScreenShotError(msg) + monitors = [] + monitors_reply = xcb.randr_get_monitors(self.conn, self.drawable, 1) timestamp = monitors_reply.timestamp for randr_monitor in xcb.randr_get_monitors_monitors(monitors_reply): @@ -314,7 +319,9 @@ def _monitors_from_randr_monitors( chosen_output = self._choose_randr_output(outputs, primary_output) monitor |= self._randr_output_ids(chosen_output, timestamp, edid_atom) - self._monitors.append(monitor) + monitors.append(monitor) + + return monitors def _monitors_from_randr_crtcs( self, @@ -322,11 +329,13 @@ def _monitors_from_randr_crtcs( primary_output: xcb.RandrOutput | None, edid_atom: xcb.Atom | None, /, - ) -> None: + ) -> Monitors: if self.conn is None: msg = "Cannot identify monitors while the connection is closed" raise ScreenShotError(msg) + monitors = [] + screen_resources: xcb.RandrGetScreenResourcesReply | xcb.RandrGetScreenResourcesCurrentReply if hasattr(LIB.randr, "xcb_randr_get_screen_resources_current") and randr_version >= (1, 3): screen_resources = xcb.randr_get_screen_resources_current(self.conn, self.drawable) @@ -356,9 +365,11 @@ def _monitors_from_randr_crtcs( if primary_output is not None: monitor["is_primary"] = chosen_output == primary_output - self._monitors.append(monitor) + monitors.append(monitor) + + return monitors - def _cursor_impl_check_xfixes(self) -> bool: + def _cursor_check_xfixes(self) -> bool: """Check XFixes availability and version. :returns: ``True`` if the server provides XFixes with a compatible @@ -377,7 +388,7 @@ def _cursor_impl_check_xfixes(self) -> bool: # everything these days is much more modern. return (reply.major_version, reply.minor_version) >= (2, 0) - def _cursor_impl(self) -> ScreenShot: + def cursor(self) -> ScreenShot: """Capture the current cursor image. Pixels are returned in BGRA ordering. @@ -390,7 +401,7 @@ def _cursor_impl(self) -> ScreenShot: raise ScreenShotError(msg) if self._xfixes_ready is None: - self._xfixes_ready = self._cursor_impl_check_xfixes() + self._xfixes_ready = self._cursor_check_xfixes() if not self._xfixes_ready: msg = "Server does not have XFixes, or the version is too old." raise ScreenShotError(msg) @@ -408,9 +419,9 @@ def _cursor_impl(self) -> ScreenShot: # We don't need to do the same array slice-and-dice work as the Xlib-based implementation: Xlib has an # unfortunate historical accident that makes it have to return the cursor image in a different format. - return self.cls_image(data, region) + return ScreenShot(data, region) - def _grab_impl_xgetimage(self, monitor: Monitor, /) -> ScreenShot: + def _grab_xgetimage(self, monitor: Monitor, /) -> bytearray: """Retrieve pixels from a monitor using ``GetImage``. Used by the XGetImage backend and by the XShmGetImage backend in @@ -450,4 +461,4 @@ def _grab_impl_xgetimage(self, monitor: Monitor, /) -> ScreenShot: ) raise ScreenShotError(msg) - return self.cls_image(img_data, monitor) + return img_data diff --git a/src/mss/linux/xgetimage.py b/src/mss/linux/xgetimage.py index e256ac8f..ad28c056 100644 --- a/src/mss/linux/xgetimage.py +++ b/src/mss/linux/xgetimage.py @@ -9,12 +9,13 @@ .. versionadded:: 10.2.0 """ -from mss.linux.base import MSSXCBBase +from mss.linux.base import MSSImplXCBBase from mss.models import Monitor -from mss.screenshot import ScreenShot +__all__ = () -class MSS(MSSXCBBase): + +class MSSImplXGetImage(MSSImplXCBBase): """XCB backend using XGetImage requests on GNU/Linux. .. seealso:: @@ -22,6 +23,6 @@ class MSS(MSSXCBBase): Lists constructor parameters. """ - def _grab_impl(self, monitor: Monitor) -> ScreenShot: + def grab(self, monitor: Monitor) -> bytearray: """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" - return super()._grab_impl_xgetimage(monitor) + return super()._grab_xgetimage(monitor) diff --git a/src/mss/linux/xlib.py b/src/mss/linux/xlib.py index d4b28be0..a92b19f9 100644 --- a/src/mss/linux/xlib.py +++ b/src/mss/linux/xlib.py @@ -35,14 +35,14 @@ from threading import Lock, current_thread, local from typing import TYPE_CHECKING, Any -from mss.base import MSSBase +from mss.base import MSSImplementation from mss.exception import ScreenShotError +from mss.screenshot import ScreenShot if TYPE_CHECKING: - from mss.models import CFunctions, Monitor - from mss.screenshot import ScreenShot + from mss.models import CFunctions, Monitor, Monitors -__all__ = ("MSS",) +__all__ = () # Global lock protecting access to Xlib calls. @@ -387,7 +387,7 @@ def _validate_x11( } -class MSS(MSSBase): +class MSSImplXlib(MSSImplementation): """Multiple ScreenShots implementation for GNU/Linux. It uses intensively the Xlib and its Xrandr extension. @@ -404,14 +404,16 @@ class MSS(MSSBase): different threads simultaneously may still cause problems. .. seealso:: - :py:class:`mss.base.MSSBase` + :py:class:`mss.MSS` Lists other parameters. """ __slots__ = {"_handles", "xfixes", "xlib", "xrandr"} - def __init__(self, /, **kwargs: Any) -> None: - super().__init__(**kwargs) + def __init__(self, /, display: bytes | str = b"", **kwargs: Any) -> None: + requested_with_cursor = bool(kwargs.pop("with_cursor", False)) + effective_with_cursor = requested_with_cursor and bool(_XFIXES) + super().__init__(with_cursor=effective_with_cursor, **kwargs) # Available thread-specific variables self._handles = local() @@ -420,7 +422,6 @@ def __init__(self, /, **kwargs: Any) -> None: self._handles.original_error_handler = None self._handles.root = None - display = kwargs.get("display", b"") if not display: try: display = os.environ["DISPLAY"].encode("utf-8") @@ -447,12 +448,10 @@ def __init__(self, /, **kwargs: Any) -> None: #: :meta private: self.xrandr = cdll.LoadLibrary(_XRANDR) - if self.with_cursor: - if _XFIXES: - #: :meta private: - self.xfixes = cdll.LoadLibrary(_XFIXES) - else: - self.with_cursor = False + if effective_with_cursor: + # MyPy doesn't quite realize that won't be called if _XFIXES is None. + #: :meta private: + self.xfixes = cdll.LoadLibrary(_XFIXES) if _XFIXES is not None else None self._set_cfunctions() @@ -472,7 +471,7 @@ def __init__(self, /, **kwargs: Any) -> None: msg = "Xrandr not enabled." raise ScreenShotError(msg) - def _close_impl(self) -> None: + def close(self) -> None: # Clean-up if self._handles.display: with _lock: @@ -527,11 +526,12 @@ def _set_cfunctions(self) -> None: errcheck = None if func == "XSetErrorHandler" else _validate_x11 cfactory(attrs[attr], func, argtypes, restype, errcheck=errcheck) - def _monitors_impl(self) -> None: + def monitors(self) -> Monitors: """Get positions of monitors. It will populate self._monitors.""" display = self._handles.display int_ = int xrandr = self.xrandr + monitors = [] with _lock: xrandr_major = c_int(0) @@ -541,7 +541,7 @@ def _monitors_impl(self) -> None: # All monitors gwa = XWindowAttributes() self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa)) - self._monitors.append( + monitors.append( {"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)}, ) @@ -564,7 +564,7 @@ def _monitors_impl(self) -> None: xrandr.XRRFreeCrtcInfo(crtc) continue - self._monitors.append( + monitors.append( { "left": int_(crtc.x), "top": int_(crtc.y), @@ -575,7 +575,9 @@ def _monitors_impl(self) -> None: xrandr.XRRFreeCrtcInfo(crtc) xrandr.XRRFreeScreenResources(mon) - def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: + return monitors + + def grab(self, monitor: Monitor, /) -> bytearray: """Retrieve all pixels from a monitor. Pixels have to be RGB.""" with _lock: @@ -606,10 +608,13 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: with _lock: self.xlib.XDestroyImage(ximage) - return self.cls_image(data, monitor) + return data - def _cursor_impl(self) -> ScreenShot: + def cursor(self) -> ScreenShot | None: """Retrieve all cursor data. Pixels have to be RGB.""" + if self.xfixes is None: + return None + # Read data of cursor/mouse-pointer with _lock: ximage = self.xfixes.XFixesGetCursorImage(self._handles.display) @@ -634,4 +639,4 @@ def _cursor_impl(self) -> ScreenShot: data[1::4] = raw[1::8] data[::4] = raw[::8] - return self.cls_image(data, region) + return ScreenShot(data, region) diff --git a/src/mss/linux/xshmgetimage.py b/src/mss/linux/xshmgetimage.py index 454d72f7..68b8cdfd 100644 --- a/src/mss/linux/xshmgetimage.py +++ b/src/mss/linux/xshmgetimage.py @@ -20,12 +20,13 @@ from mss.exception import ScreenShotError from mss.linux import xcb -from mss.linux.base import ALL_PLANES, MSSXCBBase +from mss.linux.base import ALL_PLANES, MSSImplXCBBase from mss.linux.xcbhelpers import LIB, XProtoError if TYPE_CHECKING: from mss.models import Monitor - from mss.screenshot import ScreenShot + +__all__ = () class ShmStatus(enum.Enum): @@ -36,11 +37,11 @@ class ShmStatus(enum.Enum): UNAVAILABLE = enum.auto() # We know SHM GetImage is unusable; always use XGetImage. -class MSS(MSSXCBBase): +class MSSImplXShmGetImage(MSSImplXCBBase): """XCB backend using XShmGetImage with an automatic XGetImage fallback. .. seealso:: - :py:class:`mss.linux.base.MSSXCBBase` + :py:class:`mss.linux.base.MSSImplXCBBase` Lists constructor parameters. """ @@ -128,9 +129,9 @@ def _shm_unavailable(self, msg: str, exc: Exception) -> ShmStatus: self._shutdown_shm() return ShmStatus.UNAVAILABLE - def _close_impl(self) -> None: + def close(self) -> None: self._shutdown_shm() - super()._close_impl() + super().close() def _shutdown_shm(self) -> None: # It would be nice to also try to tell the server to detach the shmseg, but we might be in an error path @@ -143,7 +144,7 @@ def _shutdown_shm(self) -> None: os.close(self._memfd) self._memfd = None - def _grab_impl_xshmgetimage(self, monitor: Monitor) -> ScreenShot: + def _grab_xshmgetimage(self, monitor: Monitor) -> bytearray: if self.conn is None: msg = "Cannot take screenshot while the connection is closed" raise ScreenShotError(msg) @@ -189,18 +190,16 @@ def _grab_impl_xshmgetimage(self, monitor: Monitor) -> ScreenShot: # open memoryview if an exception happens, since that will prevent us from closing self._buf during the stack # unwind. with memoryview(self._buf) as img_mv: - img_data = bytearray(img_mv[:new_size]) - - return self.cls_image(img_data, monitor) + return bytearray(img_mv[:new_size]) - def _grab_impl(self, monitor: Monitor) -> ScreenShot: + def grab(self, monitor: Monitor) -> bytearray: """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" if self.shm_status == ShmStatus.UNAVAILABLE: - return super()._grab_impl_xgetimage(monitor) + return super()._grab_xgetimage(monitor) # The usual path is just the next few lines. try: - rv = self._grab_impl_xshmgetimage(monitor) + rv = self._grab_xshmgetimage(monitor) self.shm_status = ShmStatus.AVAILABLE except XProtoError as e: if self.shm_status != ShmStatus.UNKNOWN: @@ -213,7 +212,7 @@ def _grab_impl(self, monitor: Monitor) -> ScreenShot: # altogether: security-hardened servers, for instance, or some XPrint servers. But let's make sure, by # testing the same request through XGetImage. try: - rv = super()._grab_impl_xgetimage(monitor) + rv = super()._grab_xgetimage(monitor) except XProtoError: # noqa: TRY203 # The XGetImage also failed, so we don't know anything about whether XShmGetImage is usable. Maybe # the user sent an out-of-bounds request. Maybe it's a security-hardened server. We're not sure what diff --git a/src/mss/windows.py b/src/mss/windows.py index 0a27e563..d36cdafe 100644 --- a/src/mss/windows.py +++ b/src/mss/windows.py @@ -8,6 +8,7 @@ import ctypes import sys +import warnings from ctypes import POINTER, WINFUNCTYPE, Structure, WinError, _Pointer from ctypes.wintypes import ( BOOL, @@ -30,20 +31,36 @@ ) from typing import TYPE_CHECKING -from mss.base import MSSBase +from mss.base import MSS as _MSS +from mss.base import MSSImplementation from mss.exception import ScreenShotError if TYPE_CHECKING: from typing import Any, Callable - from mss.models import CFunctionsErrChecked, Monitor - from mss.screenshot import ScreenShot + from mss.models import CFunctionsErrChecked, Monitor, Monitors __all__ = ("MSS",) BACKENDS = ["default"] +class MSS(_MSS): + """Deprecated Windows compatibility constructor. + + Use :class:`mss.MSS` instead. + """ + + def __init__(self, /, **kwargs: Any) -> None: + # TODO(jholveck): #493 Remove compatibility constructor after 10.x transition period. + warnings.warn( + "mss.windows.MSS is deprecated and will be removed in 11.0; use mss.MSS instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) + + LPCRECT = POINTER(RECT) # Actually a const pointer, but ctypes has no const. CAPTUREBLT = 0x40000000 DIB_RGB_COLORS = 0 @@ -165,7 +182,7 @@ def _errcheck(result: BOOL | _Pointer, func: Callable, arguments: tuple) -> tupl } -class MSS(MSSBase): +class MSSImplWindows(MSSImplementation): """Multiple ScreenShots implementation for Microsoft Windows. This implementation uses CreateDIBSection for direct memory access to pixel data, @@ -176,7 +193,7 @@ class MSS(MSSBase): .. seealso:: - :py:class:`mss.base.MSSBase` + :py:class:`mss.MSS` Lists constructor parameters. """ @@ -192,8 +209,13 @@ class MSS(MSSBase): "user32", } - def __init__(self, /, **kwargs: Any) -> None: - super().__init__(**kwargs) + def __init__(self, backend: str = "default", **kwargs: Any) -> None: + kwargs.pop("with_cursor", None) + super().__init__(with_cursor=False, **kwargs) + + if backend != "default": + msg = 'The only valid backend on this platform is "default".' + raise ScreenShotError(msg) # user32 and gdi32 should not be changed after initialization. self.user32 = ctypes.WinDLL("user32", use_last_error=True) @@ -211,7 +233,7 @@ def __init__(self, /, **kwargs: Any) -> None: bmi = BITMAPINFO() bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) - # biWidth and biHeight are set in _grab_impl(). + # biWidth and biHeight are set in grab(). bmi.bmiHeader.biPlanes = 1 # Always 1 bmi.bmiHeader.biBitCount = 32 # 32-bit RGBX bmi.bmiHeader.biCompression = 0 # 0 = BI_RGB (no compression) @@ -222,7 +244,7 @@ def __init__(self, /, **kwargs: Any) -> None: bmi.bmiHeader.biClrImportant = 0 self._bmi = bmi - def _close_impl(self) -> None: + def close(self) -> None: # Clean-up if self._dib: self.gdi32.DeleteObject(self._dib) @@ -260,14 +282,15 @@ def _set_dpi_awareness(self) -> None: # Windows Vista, 7, 8, and Server 2012 self.user32.SetProcessDPIAware() - def _monitors_impl(self) -> None: - """Get positions of monitors. It will populate self._monitors.""" + def monitors(self) -> Monitors: int_ = int user32 = self.user32 get_system_metrics = user32.GetSystemMetrics + monitors = [] + # All monitors - self._monitors.append( + monitors.append( { "left": int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN "top": int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN @@ -326,12 +349,14 @@ def callback(hmonitor: HMONITOR, _data: HDC, rect: LPRECT, _dc: LPARAM) -> bool: mon_dict["name"] = device_string if unique_id is not None: mon_dict["unique_id"] = unique_id - self._monitors.append(mon_dict) + monitors.append(mon_dict) return True user32.EnumDisplayMonitors(0, None, callback, 0) - def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: + return monitors + + def grab(self, monitor: Monitor, /) -> bytearray: """Retrieve all pixels from a monitor using CreateDIBSection. CreateDIBSection creates a DIB with system-managed memory backing, @@ -384,8 +409,8 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: # Read directly from DIB memory via the cached array view assert self._dib_array is not None # noqa: S101 for type checker - return self.cls_image(bytearray(self._dib_array), monitor) + return bytearray(self._dib_array) - def _cursor_impl(self) -> ScreenShot | None: + def cursor(self) -> None: """Retrieve all cursor data. Pixels have to be RGB.""" - return None + return diff --git a/src/tests/bench_bgra2rgb.py b/src/tests/bench_bgra2rgb.py index 6acaffb3..27eadb0d 100644 --- a/src/tests/bench_bgra2rgb.py +++ b/src/tests/bench_bgra2rgb.py @@ -59,7 +59,7 @@ def pil_frombytes(im: ScreenShot) -> bytes: def benchmark() -> None: - with mss.mss() as sct: + with mss.MSS() as sct: im = sct.grab(sct.monitors[0]) for func in ( pil_frombytes, diff --git a/src/tests/bench_general.py b/src/tests/bench_general.py index 5de4f1c1..d7d223af 100644 --- a/src/tests/bench_general.py +++ b/src/tests/bench_general.py @@ -36,26 +36,26 @@ if TYPE_CHECKING: from collections.abc import Callable - from mss.base import MSSBase + from mss import MSS from mss.screenshot import ScreenShot -def grab(sct: MSSBase) -> ScreenShot: +def grab(sct: MSS) -> ScreenShot: monitor = {"top": 144, "left": 80, "width": 1397, "height": 782} return sct.grab(monitor) -def access_rgb(sct: MSSBase) -> bytes: +def access_rgb(sct: MSS) -> bytes: im = grab(sct) return im.rgb -def output(sct: MSSBase, filename: str | None = None) -> None: +def output(sct: MSS, filename: str | None = None) -> None: rgb = access_rgb(sct) mss.tools.to_png(rgb, (1397, 782), output=filename) -def save(sct: MSSBase) -> None: +def save(sct: MSS) -> None: output(sct, filename="screenshot.png") @@ -63,7 +63,7 @@ def benchmark(func: Callable) -> None: count = 0 start = time() - with mss.mss() as sct: + with mss.MSS() as sct: while (time() - start) % 60 < 10: count += 1 func(sct) diff --git a/src/tests/bench_grab_windows.py b/src/tests/bench_grab_windows.py index 844f27f5..b666ab5e 100644 --- a/src/tests/bench_grab_windows.py +++ b/src/tests/bench_grab_windows.py @@ -24,7 +24,7 @@ def benchmark_grab() -> tuple[float, float]: Returns (avg_ms, fps) for comparison. """ - with mss.mss() as sct: + with mss.MSS() as sct: monitor = sct.monitors[1] # Primary monitor width, height = monitor["width"], monitor["height"] @@ -65,7 +65,7 @@ def benchmark_grab_varying_sizes() -> None: print("\nVarying size benchmark:") print("-" * 50) - with mss.mss() as sct: + with mss.MSS() as sct: for width, height in sizes: monitor = {"top": 0, "left": 0, "width": width, "height": height} @@ -109,8 +109,8 @@ def benchmark_raw_bitblt() -> None: srccopy = 0x00CC0020 captureblt = 0x40000000 - with mss.mss() as sct: - assert isinstance(sct, mss.windows.MSS) + with mss.MSS() as sct: + assert isinstance(sct._impl, mss.windows.MSSImplWindows) monitor = sct.monitors[1] width, height = monitor["width"], monitor["height"] left, top = monitor["left"], monitor["top"] @@ -118,8 +118,8 @@ def benchmark_raw_bitblt() -> None: # Force region setup sct.grab(monitor) - srcdc = sct._srcdc - memdc = sct._memdc + srcdc = sct._impl._srcdc + memdc = sct._impl._memdc print(f"Raw BitBlt benchmark ({width}x{height})") print("=" * 50) @@ -145,7 +145,7 @@ def analyze_frame_timing() -> None: """Analyze individual frame timing to detect VSync/DWM patterns.""" num_samples = 200 - with mss.mss() as sct: + with mss.MSS() as sct: monitor = sct.monitors[1] width, height = monitor["width"], monitor["height"] diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 08b98548..6440bff5 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -11,8 +11,7 @@ import pytest -from mss import mss -from mss.base import MSSBase +from mss import MSS from mss.linux import xcb, xlib @@ -76,10 +75,10 @@ def backend(request: pytest.FixtureRequest) -> str: @pytest.fixture -def mss_impl(backend: str) -> Callable[..., MSSBase]: +def mss_impl(backend: str) -> Callable[..., MSS]: # We can't just use partial here, since it will read $DISPLAY at the wrong time. This can cause problems, # depending on just how the fixtures get run. - return lambda *args, **kwargs: mss(*args, display=os.getenv("DISPLAY"), backend=backend, **kwargs) + return lambda *args, **kwargs: MSS(*args, display=os.getenv("DISPLAY"), backend=backend, **kwargs) @pytest.fixture(autouse=True, scope="session") diff --git a/src/tests/test_cls_image.py b/src/tests/test_cls_image.py index cbf02aed..16564575 100644 --- a/src/tests/test_cls_image.py +++ b/src/tests/test_cls_image.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import Any -from mss.base import MSSBase +from mss import MSS from mss.models import Monitor @@ -15,7 +15,7 @@ def __init__(self, data: bytearray, monitor: Monitor, **_: Any) -> None: self.monitor = monitor -def test_custom_cls_image(mss_impl: Callable[..., MSSBase]) -> None: +def test_custom_cls_image(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: sct.cls_image = SimpleScreenShot # type: ignore[assignment] mon1 = sct.monitors[1] diff --git a/src/tests/test_compat_10_1.py b/src/tests/test_compat_10_1.py new file mode 100644 index 00000000..57dec107 --- /dev/null +++ b/src/tests/test_compat_10_1.py @@ -0,0 +1,164 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import platform +import warnings +from collections.abc import Callable +from os import getenv +from typing import Protocol, cast + +import pytest + +import mss +from mss import MSS +from mss.base import MSSBase + + +class PlatformModule(Protocol): + MSS: type[MSS] + + +MSSFactory = Callable[[], MSS] +MSSFactoryGetter = Callable[[], MSSFactory] + + +def _factory_from_import_style() -> MSSFactory: + from mss import mss as mss_factory # noqa: PLC0415 + + return cast("MSSFactory", mss_factory) + + +def _factory_from_module_style() -> MSSFactory: + import mss as mss_module # noqa: PLC0415 + + return mss_module.mss + + +def _platform_module() -> PlatformModule: + os_ = platform.system().lower() + + if os_ == "linux": + import mss.linux as mss_platform # noqa: PLC0415 + + return cast("PlatformModule", mss_platform) + if os_ == "darwin": + import mss.darwin as mss_platform # type: ignore[no-redef] # noqa: PLC0415 + + return cast("PlatformModule", mss_platform) + if os_ == "windows": + import mss.windows as mss_platform # type: ignore[no-redef] # noqa: PLC0415 + + return cast("PlatformModule", mss_platform) + msg = f"Unsupported platform for compatibility test: {os_!r}" + raise AssertionError(msg) + + +def _platform_factory_from_import_style() -> type[MSS]: + os_ = platform.system().lower() + + if os_ == "linux": + import mss.linux # noqa: PLC0415 + + return mss.linux.MSS + if os_ == "darwin": + import mss.darwin # noqa: PLC0415 + + return mss.darwin.MSS + if os_ == "windows": + import mss.windows # noqa: PLC0415 + + return mss.windows.MSS + msg = f"Unsupported platform for compatibility test: {os_!r}" + raise AssertionError(msg) + + +@pytest.mark.parametrize( + "factory_getter", + [ + lambda: mss.mss, + _factory_from_import_style, + _factory_from_module_style, + ], +) +def test_mss_factory_documented_styles_return_mssbase(factory_getter: MSSFactoryGetter) -> None: + factory = factory_getter() + + with pytest.warns(DeprecationWarning, match=r"^mss\.mss is deprecated"): + context = factory() + + with context as sct: + assert isinstance(sct, MSSBase) + assert isinstance(sct, MSS) + + +def test_documented_style_platform_import_mss() -> None: + mss_factory = _platform_factory_from_import_style() + + with pytest.warns(DeprecationWarning, match=r"^mss\..*\.MSS is deprecated"): + context = mss_factory() + + with context as sct: + assert isinstance(sct, MSSBase) + + +def test_direct_mss_constructor_has_no_deprecation_warning() -> None: + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always", DeprecationWarning) + with mss.MSS() as sct: + assert isinstance(sct, MSS) + assert not [warning for warning in captured if issubclass(warning.category, DeprecationWarning)] + + +def test_mssbase_alias_stays_compatible() -> None: + # 10.1-compatible typing/import path. + assert MSSBase is MSS + + +def test_platform_mss_constructor_works_on_current_platform() -> None: + mss_platform = _platform_module() + + with pytest.warns(DeprecationWarning, match=r"^mss\..*\.MSS is deprecated"): + sct_context = mss_platform.MSS() + + with sct_context as sct: + assert isinstance(sct, mss_platform.MSS) + assert isinstance(sct, MSSBase) + assert isinstance(sct, MSS) + + +def test_factory_and_platform_constructor_are_compatible_types() -> None: + mss_platform = _platform_module() + + with pytest.warns(DeprecationWarning, match=r"^mss\..*\.MSS is deprecated"): + from_platform_context = mss_platform.MSS() + + with pytest.warns(DeprecationWarning, match=r"^mss\.mss is deprecated"): + from_factory_context = mss.mss() + + with from_factory_context as from_factory, from_platform_context as from_platform: + assert type(from_factory) is MSS + assert type(from_platform) is mss_platform.MSS + assert isinstance(from_platform, MSS) + assert isinstance(from_platform, MSSBase) + + +def test_deprecated_factory_accepts_documented_kwargs() -> None: + os_ = platform.system().lower() + kwargs: dict[str, object] = {"compression_level": 1, "with_cursor": True} + + if os_ == "linux": + display = getenv("DISPLAY") + assert display + kwargs["display"] = display + elif os_ == "darwin": + kwargs["max_displays"] = 1 + elif os_ != "windows": + msg = f"Unsupported platform for compatibility test: {os_!r}" + raise AssertionError(msg) + + with pytest.warns(DeprecationWarning, match=r"^mss\.mss is deprecated"): + context = mss.mss(**kwargs) + + with context as sct: + assert isinstance(sct, MSSBase) diff --git a/src/tests/test_compat_exports.py b/src/tests/test_compat_exports.py new file mode 100644 index 00000000..92cfefb9 --- /dev/null +++ b/src/tests/test_compat_exports.py @@ -0,0 +1,19 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import mss +import mss.base + + +def test_top_level_export_surface_exists() -> None: + # TODO(jholveck): #493 Remove compatibility-only export checks after 10.x transition period. + assert hasattr(mss, "mss") + assert hasattr(mss, "MSS") + assert hasattr(mss, "ScreenShotError") + assert hasattr(mss, "__version__") + + +def test_mssbase_compat_symbol_exists() -> None: + # TODO(jholveck): #493 Remove compatibility-only export checks after 10.x transition period. + assert hasattr(mss.base, "MSSBase") diff --git a/src/tests/test_compat_linux_api.py b/src/tests/test_compat_linux_api.py new file mode 100644 index 00000000..4360b1f3 --- /dev/null +++ b/src/tests/test_compat_linux_api.py @@ -0,0 +1,30 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import platform + +import pytest + +import mss.linux + + +@pytest.mark.skipif(platform.system().lower() != "linux", reason="GNU/Linux compatibility checks") +def test_linux_10_1_documented_symbols_are_reexported() -> None: + # TODO(jholveck): #493 Drop this compatibility-only re-export check after 10.x transition period. + expected = [ + "CFUNCTIONS", + "Display", + "PLAINMASK", + "XErrorEvent", + "XFixesCursorImage", + "XImage", + "XRRCrtcInfo", + "XRRModeInfo", + "XRRScreenResources", + "XWindowAttributes", + "ZPIXMAP", + ] + + for symbol in expected: + assert hasattr(mss.linux, symbol) diff --git a/src/tests/test_find_monitors.py b/src/tests/test_find_monitors.py index 1194c701..adf7a55b 100644 --- a/src/tests/test_find_monitors.py +++ b/src/tests/test_find_monitors.py @@ -4,15 +4,15 @@ from collections.abc import Callable -from mss.base import MSSBase +from mss import MSS -def test_get_monitors(mss_impl: Callable[..., MSSBase]) -> None: +def test_get_monitors(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: assert sct.monitors -def test_keys_aio(mss_impl: Callable[..., MSSBase]) -> None: +def test_keys_aio(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: all_monitors = sct.monitors[0] assert "top" in all_monitors @@ -21,7 +21,7 @@ def test_keys_aio(mss_impl: Callable[..., MSSBase]) -> None: assert "width" in all_monitors -def test_keys_monitor_1(mss_impl: Callable[..., MSSBase]) -> None: +def test_keys_monitor_1(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: mon1 = sct.monitors[1] assert "top" in mon1 @@ -30,7 +30,7 @@ def test_keys_monitor_1(mss_impl: Callable[..., MSSBase]) -> None: assert "width" in mon1 -def test_dimensions(mss_impl: Callable[..., MSSBase]) -> None: +def test_dimensions(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: mon = sct.monitors[1] assert mon["width"] > 0 diff --git a/src/tests/test_get_pixels.py b/src/tests/test_get_pixels.py index 53e26e4d..cc94aba7 100644 --- a/src/tests/test_get_pixels.py +++ b/src/tests/test_get_pixels.py @@ -7,11 +7,11 @@ import pytest -from mss.base import MSSBase, ScreenShot +from mss import MSS, ScreenShot from mss.exception import ScreenShotError -def test_grab_monitor(mss_impl: Callable[..., MSSBase]) -> None: +def test_grab_monitor(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: for mon in sct.monitors: image = sct.grab(mon) @@ -20,7 +20,7 @@ def test_grab_monitor(mss_impl: Callable[..., MSSBase]) -> None: assert isinstance(image.rgb, bytes) -def test_grab_part_of_screen(mss_impl: Callable[..., MSSBase]) -> None: +def test_grab_part_of_screen(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: for width, height in itertools.product(range(1, 42), range(1, 42)): monitor = {"top": 160, "left": 160, "width": width, "height": height} diff --git a/src/tests/test_gnu_linux.py b/src/tests/test_gnu_linux.py index 048d6f38..26861aae 100644 --- a/src/tests/test_gnu_linux.py +++ b/src/tests/test_gnu_linux.py @@ -17,7 +17,7 @@ import mss.linux import mss.linux.xcb import mss.linux.xlib -from mss.base import MSSBase +from mss import MSS from mss.exception import ScreenShotError if TYPE_CHECKING: @@ -67,8 +67,8 @@ def display() -> Generator: def test_default_backend(display: str) -> None: - with mss.mss(display=display) as sct: - assert isinstance(sct, MSSBase) + with mss.MSS(display=display) as sct: + assert isinstance(sct, MSS) @pytest.mark.skipif(PYPY, reason="Failure on PyPy") @@ -77,37 +77,41 @@ def test_factory_systems(monkeypatch: pytest.MonkeyPatch, backend: str) -> None: Too hard to maintain the test for all platforms, so test only on GNU/Linux. + + TODO: Revisit the non-Linux branches before the final PR. Confirm the + intended cross-platform behavior after the strategy refactor and, if + appropriate, narrow the accepted exception types again. """ # GNU/Linux monkeypatch.setattr(platform, "system", lambda: "LINUX") - with mss.mss(backend=backend) as sct: - assert isinstance(sct, MSSBase) + with mss.MSS(backend=backend) as sct: + assert isinstance(sct, MSS) monkeypatch.undo() # macOS monkeypatch.setattr(platform, "system", lambda: "Darwin") # ValueError on macOS Big Sur - with pytest.raises((ScreenShotError, ValueError)), mss.mss(backend=backend): + with pytest.raises((ScreenShotError, ValueError)), mss.MSS(backend=backend): pass monkeypatch.undo() # Windows monkeypatch.setattr(platform, "system", lambda: "wInDoWs") - with pytest.raises(ImportError, match="cannot import name 'WINFUNCTYPE'"), mss.mss(backend=backend): + with pytest.raises((ImportError, ScreenShotError)), mss.MSS(backend=backend): pass def test_arg_display(display: str, backend: str, monkeypatch: pytest.MonkeyPatch) -> None: # Good value - with mss.mss(display=display, backend=backend): + with mss.MSS(display=display, backend=backend): pass # Bad `display` (missing ":" in front of the number) - with pytest.raises(ScreenShotError), mss.mss(display="0", backend=backend): + with pytest.raises(ScreenShotError), mss.MSS(display="0", backend=backend): pass # Invalid `display` that is not trivially distinguishable. - with pytest.raises(ScreenShotError), mss.mss(display=":INVALID", backend=backend): + with pytest.raises(ScreenShotError), mss.MSS(display=":INVALID", backend=backend): pass # No `DISPLAY` in envars @@ -115,14 +119,14 @@ def test_arg_display(display: str, backend: str, monkeypatch: pytest.MonkeyPatch # monkeypatch context to isolate it a bit. with monkeypatch.context() as mp: mp.delenv("DISPLAY") - with pytest.raises(ScreenShotError), mss.mss(backend=backend): + with pytest.raises(ScreenShotError), mss.MSS(backend=backend): pass def test_xerror_without_details() -> None: # Opening an invalid display with the Xlib backend will create an XError instance, but since there was no # XErrorEvent, then the details won't be filled in. Generate one. - with pytest.raises(ScreenShotError) as excinfo, mss.mss(display=":INVALID"): + with pytest.raises(ScreenShotError) as excinfo, mss.MSS(display=":INVALID"): pass exc = excinfo.value @@ -135,20 +139,20 @@ def test_xerror_without_details() -> None: @pytest.mark.without_libraries("xcb") @patch("mss.linux.xlib._X11", new=None) def test_no_xlib_library(backend: str) -> None: - with pytest.raises(ScreenShotError), mss.mss(backend=backend): + with pytest.raises(ScreenShotError), mss.MSS(backend=backend): pass @pytest.mark.without_libraries("xcb-randr") @patch("mss.linux.xlib._XRANDR", new=None) def test_no_xrandr_extension(backend: str) -> None: - with pytest.raises(ScreenShotError), mss.mss(backend=backend): + with pytest.raises(ScreenShotError), mss.MSS(backend=backend): pass -@patch("mss.linux.xlib.MSS._is_extension_enabled", new=Mock(return_value=False)) +@patch("mss.linux.xlib.MSSImplXlib._is_extension_enabled", new=Mock(return_value=False)) def test_xrandr_extension_exists_but_is_not_enabled(display: str) -> None: - with pytest.raises(ScreenShotError), mss.mss(display=display, backend="xlib"): + with pytest.raises(ScreenShotError), mss.MSS(display=display, backend="xlib"): pass @@ -158,7 +162,7 @@ def test_unsupported_depth(backend: str) -> None: with ( pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=8) as vdisplay, pytest.raises(ScreenShotError, match=r"\b8\b"), - mss.mss(display=vdisplay.new_display_var, backend=backend) as sct, + mss.MSS(display=vdisplay.new_display_var, backend=backend) as sct, ): sct.grab(sct.monitors[1]) @@ -166,23 +170,23 @@ def test_unsupported_depth(backend: str) -> None: with ( pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=16) as vdisplay, pytest.raises(ScreenShotError, match=r"\b16\b"), - mss.mss(display=vdisplay.new_display_var, backend=backend) as sct, + mss.MSS(display=vdisplay.new_display_var, backend=backend) as sct, ): sct.grab(sct.monitors[1]) def test__is_extension_enabled_unknown_name(display: str) -> None: - with mss.mss(display=display, backend="xlib") as sct: - assert isinstance(sct, mss.linux.xlib.MSS) # For Mypy - assert not sct._is_extension_enabled("NOEXT") + with mss.MSS(display=display, backend="xlib") as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + assert not sct._impl._is_extension_enabled("NOEXT") def test_fast_function_for_monitor_details_retrieval(display: str, monkeypatch: pytest.MonkeyPatch) -> None: - with mss.mss(display=display, backend="xlib") as sct: - assert isinstance(sct, mss.linux.xlib.MSS) # For Mypy - assert hasattr(sct.xrandr, "XRRGetScreenResourcesCurrent") - fast_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResourcesCurrent") - slow_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResources") + with mss.MSS(display=display, backend="xlib") as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + assert hasattr(sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + fast_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + slow_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResources") screenshot_with_fast_fn = sct.grab(sct.monitors[1]) fast_spy.assert_called() @@ -194,19 +198,19 @@ def test_fast_function_for_monitor_details_retrieval(display: str, monkeypatch: def test_client_missing_fast_function_for_monitor_details_retrieval( display: str, monkeypatch: pytest.MonkeyPatch ) -> None: - with mss.mss(display=display, backend="xlib") as sct: - assert isinstance(sct, mss.linux.xlib.MSS) # For Mypy - assert hasattr(sct.xrandr, "XRRGetScreenResourcesCurrent") + with mss.MSS(display=display, backend="xlib") as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + assert hasattr(sct._impl.xrandr, "XRRGetScreenResourcesCurrent") # Even though we're going to delete it, we'll still create a fast spy, to make sure that it isn't somehow # getting accessed through a path we hadn't considered. - fast_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResourcesCurrent") - slow_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResources") - # If we just delete sct.xrandr.XRRGetScreenResourcesCurrent, it will get recreated automatically by ctypes + fast_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + slow_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResources") + # If we just delete sct._impl.xrandr.XRRGetScreenResourcesCurrent, it will get recreated automatically by ctypes # the next time it's accessed. A Mock will remember that the attribute was explicitly deleted and hide it. - mock_xrandr = NonCallableMock(wraps=sct.xrandr) + mock_xrandr = NonCallableMock(wraps=sct._impl.xrandr) del mock_xrandr.XRRGetScreenResourcesCurrent - monkeypatch.setattr(sct, "xrandr", mock_xrandr) - assert not hasattr(sct.xrandr, "XRRGetScreenResourcesCurrent") + monkeypatch.setattr(sct._impl, "xrandr", mock_xrandr) + assert not hasattr(sct._impl.xrandr, "XRRGetScreenResourcesCurrent") screenshot_with_slow_fn = sct.grab(sct.monitors[1]) fast_spy.assert_not_called() @@ -231,11 +235,11 @@ def fake_xrrqueryversion(_dpy: _Pointer, major_p: _Pointer, minor_p: _Pointer) - minor_p[0] = 2 return 1 - with mss.mss(display=display, backend="xlib") as sct: - assert isinstance(sct, mss.linux.xlib.MSS) # For Mypy - monkeypatch.setattr(sct.xrandr, "XRRQueryVersion", fake_xrrqueryversion) - fast_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResourcesCurrent") - slow_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResources") + with mss.MSS(display=display, backend="xlib") as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + monkeypatch.setattr(sct._impl.xrandr, "XRRQueryVersion", fake_xrrqueryversion) + fast_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + slow_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResources") screenshot_with_slow_fn = sct.grab(sct.monitors[1]) fast_spy.assert_not_called() @@ -245,17 +249,17 @@ def fake_xrrqueryversion(_dpy: _Pointer, major_p: _Pointer, minor_p: _Pointer) - def test_with_cursor(display: str, backend: str) -> None: - with mss.mss(display=display, backend=backend) as sct: - assert not hasattr(sct, "xfixes") + with mss.MSS(display=display, backend=backend) as sct: + assert not hasattr(sct._impl, "xfixes") assert not sct.with_cursor screenshot_without_cursor = sct.grab(sct.monitors[1]) # 1 color: black assert set(screenshot_without_cursor.rgb) == {0} - with mss.mss(display=display, backend=backend, with_cursor=True) as sct: + with mss.MSS(display=display, backend=backend, with_cursor=True) as sct: if backend == "xlib": - assert hasattr(sct, "xfixes") + assert hasattr(sct._impl, "xfixes") assert sct.with_cursor screenshot_with_cursor = sct.grab(sct.monitors[1]) @@ -265,16 +269,17 @@ def test_with_cursor(display: str, backend: str) -> None: @patch("mss.linux.xlib._XFIXES", new=None) def test_with_cursor_but_not_xfixes_extension_found(display: str) -> None: - with mss.mss(display=display, backend="xlib", with_cursor=True) as sct: - assert not hasattr(sct, "xfixes") + with mss.MSS(display=display, backend="xlib", with_cursor=True) as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + assert not hasattr(sct._impl, "xfixes") assert not sct.with_cursor def test_with_cursor_failure(display: str) -> None: - with mss.mss(display=display, backend="xlib", with_cursor=True) as sct: - assert isinstance(sct, mss.linux.xlib.MSS) # For Mypy + with mss.MSS(display=display, backend="xlib", with_cursor=True) as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy with ( - patch.object(sct.xfixes, "XFixesGetCursorImage", return_value=None), + patch.object(sct._impl.xfixes, "XFixesGetCursorImage", return_value=None), pytest.raises(ScreenShotError), ): sct.grab(sct.monitors[1]) @@ -289,12 +294,12 @@ def test_shm_available() -> None: """ with ( pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH) as vdisplay, - mss.mss(display=vdisplay.new_display_var, backend="xshmgetimage") as sct, + mss.MSS(display=vdisplay.new_display_var, backend="xshmgetimage") as sct, ): - assert isinstance(sct, mss.linux.xshmgetimage.MSS) # For Mypy + assert isinstance(sct._impl, mss.linux.xshmgetimage.MSSImplXShmGetImage) # For Mypy # The status currently isn't established as final until a grab succeeds. sct.grab(sct.monitors[0]) - assert sct.shm_status == mss.linux.xshmgetimage.ShmStatus.AVAILABLE + assert sct._impl.shm_status == mss.linux.xshmgetimage.ShmStatus.AVAILABLE def test_shm_fallback() -> None: @@ -309,13 +314,13 @@ def test_shm_fallback() -> None: """ with ( pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH, extra_args=["-listen", "tcp"]) as vdisplay, - mss.mss(display=f"localhost{vdisplay.new_display_var}", backend="xshmgetimage") as sct, + mss.MSS(display=f"localhost{vdisplay.new_display_var}", backend="xshmgetimage") as sct, ): - assert isinstance(sct, mss.linux.xshmgetimage.MSS) # For Mypy + assert isinstance(sct._impl, mss.linux.xshmgetimage.MSSImplXShmGetImage) # For Mypy # Ensure that the grab call completes without exception. sct.grab(sct.monitors[0]) # Ensure that it really did have to fall back; otherwise, we'd need to change how we test this case. - assert sct.shm_status == mss.linux.xshmgetimage.ShmStatus.UNAVAILABLE + assert sct._impl.shm_status == mss.linux.xshmgetimage.ShmStatus.UNAVAILABLE def test_exception_while_holding_memoryview(monkeypatch: pytest.MonkeyPatch) -> None: @@ -339,8 +344,8 @@ def boom(*args: list, **kwargs: dict[str, Any]) -> bytearray: # We have to be careful about the order in which we catch things. If we were to catch and discard the exception # before the MSS object closes, it won't trigger the bug. That's why we have the pytest.raises outside the - # mss.mss block. In addition, we do as much as we can before patching bytearray, to limit its scope. - with pytest.raises(RuntimeError, match="Boom!"), mss.mss(backend="xshmgetimage") as sct: # noqa: PT012 + # mss.MSS block. In addition, we do as much as we can before patching bytearray, to limit its scope. + with pytest.raises(RuntimeError, match="Boom!"), mss.MSS(backend="xshmgetimage") as sct: # noqa: PT012 monitor = sct.monitors[0] with monkeypatch.context() as m: m.setattr(builtins, "bytearray", boom) diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py index e64537ec..a677dfde 100644 --- a/src/tests/test_implementation.py +++ b/src/tests/test_implementation.py @@ -18,7 +18,7 @@ import mss from mss.__main__ import main as entry_point -from mss.base import MSSBase +from mss.base import MSS, MSSImplementation from mss.exception import ScreenShotError from mss.screenshot import ScreenShot @@ -26,7 +26,7 @@ from collections.abc import Callable from typing import Any - from mss.models import Monitor + from mss.models import Monitor, Monitors try: from datetime import UTC @@ -37,37 +37,36 @@ UTC = timezone.utc -class MSS0(MSSBase): +class MSS0(MSSImplementation): """Nothing implemented.""" -class MSS1(MSSBase): +class MSS1(MSSImplementation): """Only `grab()` implemented.""" def grab(self, monitor: Monitor) -> None: # type: ignore[override] pass -class MSS2(MSSBase): +class MSS2(MSSImplementation): """Only `monitor` implemented.""" - @property - def monitors(self) -> list: + def monitors(self) -> Monitors: return [] @pytest.mark.parametrize("cls", [MSS0, MSS1, MSS2]) -def test_incomplete_class(cls: type[MSSBase]) -> None: +def test_incomplete_class(cls: type[MSSImplementation]) -> None: with pytest.raises(TypeError): cls() -def test_bad_monitor(mss_impl: Callable[..., MSSBase]) -> None: +def test_bad_monitor(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct, pytest.raises(ScreenShotError): sct.shot(mon=222) -def test_repr(mss_impl: Callable[..., MSSBase]) -> None: +def test_repr(mss_impl: Callable[..., MSS]) -> None: box = {"top": 0, "left": 0, "width": 10, "height": 10} expected_box = {"top": 0, "left": 0, "width": 10, "height": 10} with mss_impl() as sct: @@ -77,19 +76,19 @@ def test_repr(mss_impl: Callable[..., MSSBase]) -> None: def test_factory_no_backend() -> None: - with mss.mss() as sct: - assert isinstance(sct, MSSBase) + with mss.MSS() as sct: + assert isinstance(sct, MSS) def test_factory_current_system(backend: str) -> None: - with mss.mss(backend=backend) as sct: - assert isinstance(sct, MSSBase) + with mss.MSS(backend=backend) as sct: + assert isinstance(sct, MSS) def test_factory_unknown_system(backend: str, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(platform, "system", lambda: "Chuck Norris") with pytest.raises(ScreenShotError) as exc: - mss.mss(backend=backend) + mss.MSS(backend=backend) monkeypatch.undo() error = exc.value.args[0] @@ -145,7 +144,7 @@ def test_custom_output_pattern(self, with_cursor: bool, capsys: pytest.CaptureFi for opt in ("-o", "--out"): self._run_main(with_cursor, opt, fmt) captured = capsys.readouterr() - with mss.mss(display=os.getenv("DISPLAY")) as sct: + with mss.MSS(display=os.getenv("DISPLAY")) as sct: for mon, (monitor, line) in enumerate(zip(sct.monitors[1:], captured.out.splitlines()), 1): filename = Path(fmt.format(mon=mon, **monitor)) assert line.endswith(filename.name) @@ -197,7 +196,7 @@ def test_invalid_backend_option(self, with_cursor: bool, capsys: pytest.CaptureF @patch.object(sys, "argv", new=[]) # Prevent side effects while testing -@patch("mss.base.MSSBase.monitors", new=[]) +@patch("mss.base.MSS.monitors", new=[]) @pytest.mark.parametrize("quiet", [False, True]) def test_entry_point_error(quiet: bool, capsys: pytest.CaptureFixture) -> None: def main(*args: str) -> int: @@ -230,7 +229,7 @@ def test_entry_point_with_no_argument(capsys: pytest.CaptureFixture) -> None: assert "usage: mss" in captured.out -def test_grab_with_tuple(mss_impl: Callable[..., MSSBase]) -> None: +def test_grab_with_tuple(mss_impl: Callable[..., MSS]) -> None: left = 100 top = 100 right = 500 @@ -252,7 +251,7 @@ def test_grab_with_tuple(mss_impl: Callable[..., MSSBase]) -> None: assert im.rgb == im2.rgb -def test_grab_with_invalid_tuple(mss_impl: Callable[..., MSSBase]) -> None: +def test_grab_with_invalid_tuple(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: # Remember that rect tuples are PIL-style: (left, top, right, bottom) # Negative left/top coordinates are valid for multi-monitor setups @@ -269,7 +268,7 @@ def test_grab_with_invalid_tuple(mss_impl: Callable[..., MSSBase]) -> None: sct.grab(negative_box) -def test_grab_with_tuple_percents(mss_impl: Callable[..., MSSBase]) -> None: +def test_grab_with_tuple_percents(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: monitor = sct.monitors[1] left = monitor["left"] + monitor["width"] * 5 // 100 # 5% from the left @@ -319,7 +318,7 @@ def test_issue_169(self, backend: str) -> None: """Regression test for issue #169.""" def do_grab() -> None: - with mss.mss(backend=backend) as sct: + with mss.MSS(backend=backend) as sct: sct.grab(sct.monitors[1]) self.run_test(do_grab) @@ -332,5 +331,5 @@ def test_same_object_multiple_threads(self, backend: str) -> None: """ if backend == "xlib": pytest.skip("The xlib backend does not support this ability") - with mss.mss(backend=backend) as sct: + with mss.MSS(backend=backend) as sct: self.run_test(lambda: sct.grab(sct.monitors[1])) diff --git a/src/tests/test_issue_220.py b/src/tests/test_issue_220.py index cb174629..39369477 100644 --- a/src/tests/test_issue_220.py +++ b/src/tests/test_issue_220.py @@ -26,7 +26,7 @@ def root() -> tkinter.Tk: # type: ignore[name-defined] def take_screenshot(*, backend: str) -> None: region = {"top": 370, "left": 1090, "width": 80, "height": 390} - with mss.mss(backend=backend) as sct: + with mss.MSS(backend=backend) as sct: sct.grab(region) diff --git a/src/tests/test_leaks.py b/src/tests/test_leaks.py index f5f61677..a17f8e9c 100644 --- a/src/tests/test_leaks.py +++ b/src/tests/test_leaks.py @@ -43,12 +43,12 @@ def monitor_func() -> Callable[[], int]: def bound_instance_without_cm(*, backend: str) -> None: # Will always leak - sct = mss.mss(backend=backend) + sct = mss.MSS(backend=backend) sct.shot() def bound_instance_without_cm_but_use_close(*, backend: str) -> None: - sct = mss.mss(backend=backend) + sct = mss.MSS(backend=backend) sct.shot() sct.close() # Calling .close() twice should be possible @@ -57,17 +57,17 @@ def bound_instance_without_cm_but_use_close(*, backend: str) -> None: def unbound_instance_without_cm(*, backend: str) -> None: # Will always leak - mss.mss(backend=backend).shot() + mss.MSS(backend=backend).shot() def with_context_manager(*, backend: str) -> None: - with mss.mss(backend=backend) as sct: + with mss.MSS(backend=backend) as sct: sct.shot() def regression_issue_128(*, backend: str) -> None: """Regression test for issue #128: areas overlap.""" - with mss.mss(backend=backend) as sct: + with mss.MSS(backend=backend) as sct: area1 = {"top": 50, "left": 7, "width": 400, "height": 320, "mon": 1} sct.grab(area1) area2 = {"top": 200, "left": 200, "width": 320, "height": 320, "mon": 1} @@ -76,7 +76,7 @@ def regression_issue_128(*, backend: str) -> None: def regression_issue_135(*, backend: str) -> None: """Regression test for issue #135: multiple areas.""" - with mss.mss(backend=backend) as sct: + with mss.MSS(backend=backend) as sct: bounding_box_notes = {"top": 0, "left": 0, "width": 100, "height": 100} sct.grab(bounding_box_notes) bounding_box_test = {"top": 220, "left": 220, "width": 100, "height": 100} @@ -89,10 +89,10 @@ def regression_issue_210(*, backend: str) -> None: """Regression test for issue #210: multiple X servers.""" pyvirtualdisplay = pytest.importorskip("pyvirtualdisplay") - with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.mss(backend=backend): + with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.MSS(backend=backend): pass - with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.mss(backend=backend): + with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.MSS(backend=backend): pass diff --git a/src/tests/test_macos.py b/src/tests/test_macos.py index c89ea2a8..e343d2d5 100644 --- a/src/tests/test_macos.py +++ b/src/tests/test_macos.py @@ -49,23 +49,23 @@ def test_implementation(monkeypatch: pytest.MonkeyPatch) -> None: if version < 10.16: monkeypatch.setattr(ctypes.util, "find_library", lambda _: None) with pytest.raises(ScreenShotError): - mss.mss() + mss.MSS() monkeypatch.undo() - with mss.mss() as sct: - assert isinstance(sct, mss.darwin.MSS) # For Mypy + with mss.MSS() as sct: + assert isinstance(sct._impl, mss.darwin.MSSImplDarwin) # For Mypy # Test monitor's rotation original = sct.monitors[1] - monkeypatch.setattr(sct.core, "CGDisplayRotation", lambda _: -90.0) - sct._monitors = [] + monkeypatch.setattr(sct._impl.core, "CGDisplayRotation", lambda _: -90.0) + sct._monitors = None modified = sct.monitors[1] assert original["width"] == modified["height"] assert original["height"] == modified["width"] monkeypatch.undo() # Test bad data retrieval - monkeypatch.setattr(sct.core, "CGWindowListCreateImage", lambda *_: None) + monkeypatch.setattr(sct._impl.core, "CGWindowListCreateImage", lambda *_: None) with pytest.raises(ScreenShotError): sct.grab(sct.monitors[1]) @@ -75,7 +75,7 @@ def test_scaling_on() -> None: # Grab a 1x1 screenshot region = {"top": 0, "left": 0, "width": 1, "height": 1} - with mss.mss() as sct: + with mss.MSS() as sct: # Nominal resolution, i.e.: scaling is off assert sct.grab(region).size[0] == 1 diff --git a/src/tests/test_primary_monitor.py b/src/tests/test_primary_monitor.py index 5e32cd35..160abc82 100644 --- a/src/tests/test_primary_monitor.py +++ b/src/tests/test_primary_monitor.py @@ -7,10 +7,10 @@ import pytest -from mss.base import MSSBase +from mss import MSS -def test_primary_monitor(mss_impl: Callable[..., MSSBase]) -> None: +def test_primary_monitor(mss_impl: Callable[..., MSS]) -> None: """Test that primary_monitor property works correctly.""" with mss_impl() as sct: primary = sct.primary_monitor @@ -38,7 +38,7 @@ def test_primary_monitor_coordinates_windows() -> None: """Test that on Windows, the primary monitor has coordinates at (0, 0).""" import mss # noqa: PLC0415 - with mss.mss() as sct: + with mss.MSS() as sct: primary = sct.primary_monitor if primary.get("is_primary", False): # On Windows, the primary monitor is at (0, 0) diff --git a/src/tests/test_save.py b/src/tests/test_save.py index ae3b7cbf..275c4d11 100644 --- a/src/tests/test_save.py +++ b/src/tests/test_save.py @@ -8,7 +8,7 @@ import pytest -from mss.base import MSSBase +from mss import MSS try: from datetime import UTC @@ -19,12 +19,12 @@ UTC = timezone.utc -def test_at_least_2_monitors(mss_impl: Callable[..., MSSBase]) -> None: +def test_at_least_2_monitors(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: assert list(sct.save(mon=0)) -def test_files_exist(mss_impl: Callable[..., MSSBase]) -> None: +def test_files_exist(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: for filename in sct.save(): assert Path(filename).is_file() @@ -35,7 +35,7 @@ def test_files_exist(mss_impl: Callable[..., MSSBase]) -> None: assert Path("fullscreen.png").is_file() -def test_callback(mss_impl: Callable[..., MSSBase]) -> None: +def test_callback(mss_impl: Callable[..., MSS]) -> None: def on_exists(fname: str) -> None: file = Path(fname) if Path(file).is_file(): @@ -49,14 +49,14 @@ def on_exists(fname: str) -> None: assert Path(filename).is_file() -def test_output_format_simple(mss_impl: Callable[..., MSSBase]) -> None: +def test_output_format_simple(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: filename = sct.shot(mon=1, output="mon-{mon}.png") assert filename == "mon-1.png" assert Path(filename).is_file() -def test_output_format_positions_and_sizes(mss_impl: Callable[..., MSSBase]) -> None: +def test_output_format_positions_and_sizes(mss_impl: Callable[..., MSS]) -> None: fmt = "sct-{top}x{left}_{width}x{height}.png" with mss_impl() as sct: filename = sct.shot(mon=1, output=fmt) @@ -64,7 +64,7 @@ def test_output_format_positions_and_sizes(mss_impl: Callable[..., MSSBase]) -> assert Path(filename).is_file() -def test_output_format_date_simple(mss_impl: Callable[..., MSSBase]) -> None: +def test_output_format_date_simple(mss_impl: Callable[..., MSS]) -> None: fmt = "sct_{mon}-{date}.png" with mss_impl() as sct: try: @@ -75,7 +75,7 @@ def test_output_format_date_simple(mss_impl: Callable[..., MSSBase]) -> None: pytest.mark.xfail("Default date format contains ':' which is not allowed.") -def test_output_format_date_custom(mss_impl: Callable[..., MSSBase]) -> None: +def test_output_format_date_custom(mss_impl: Callable[..., MSS]) -> None: fmt = "sct_{date:%Y-%m-%d}.png" with mss_impl() as sct: filename = sct.shot(mon=1, output=fmt) diff --git a/src/tests/test_setup.py b/src/tests/test_setup.py index 0772d3f6..892cb76f 100644 --- a/src/tests/test_setup.py +++ b/src/tests/test_setup.py @@ -3,6 +3,7 @@ """ import platform +import sys import tarfile from subprocess import STDOUT, check_call, check_output from zipfile import ZipFile @@ -17,9 +18,9 @@ pytest.importorskip("build") pytest.importorskip("twine") -SDIST = ["python", "-m", "build", "--sdist"] -WHEEL = ["python", "-m", "build", "--wheel"] -CHECK = ["twine", "check", "--strict"] +SDIST = [sys.executable, "-m", "build", "--sdist"] +WHEEL = [sys.executable, "-m", "build", "--wheel"] +CHECK = [sys.executable, "-m", "twine", "check", "--strict"] def test_sdist() -> None: @@ -91,6 +92,9 @@ def test_sdist() -> None: f"mss-{__version__}/src/tests/res/monitor-1024x768.raw.zip", f"mss-{__version__}/src/tests/test_bgra_to_rgb.py", f"mss-{__version__}/src/tests/test_cls_image.py", + f"mss-{__version__}/src/tests/test_compat_10_1.py", + f"mss-{__version__}/src/tests/test_compat_exports.py", + f"mss-{__version__}/src/tests/test_compat_linux_api.py", f"mss-{__version__}/src/tests/test_find_monitors.py", f"mss-{__version__}/src/tests/test_get_pixels.py", f"mss-{__version__}/src/tests/test_gnu_linux.py", diff --git a/src/tests/test_tools.py b/src/tests/test_tools.py index 7183b2d6..f4575666 100644 --- a/src/tests/test_tools.py +++ b/src/tests/test_tools.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from mss.base import MSSBase + from mss import MSS WIDTH = 10 HEIGHT = 10 @@ -32,7 +32,7 @@ def assert_is_valid_png(*, raw: bytes | None = None, file: Path | None = None) - pytest.fail(reason="invalid PNG data") -def test_bad_compression_level(mss_impl: Callable[..., MSSBase]) -> None: +def test_bad_compression_level(mss_impl: Callable[..., MSS]) -> None: with mss_impl(compression_level=42) as sct, pytest.raises(Exception, match="Bad compression level"): sct.shot() diff --git a/src/tests/test_windows.py b/src/tests/test_windows.py index b07c0e05..27211c19 100644 --- a/src/tests/test_windows.py +++ b/src/tests/test_windows.py @@ -18,51 +18,54 @@ def test_region_caching() -> None: """The region to grab is cached, ensure this is well-done.""" - with mss.mss() as sct: - assert isinstance(sct, mss.windows.MSS) # For Mypy + with mss.MSS() as sct: + assert isinstance(sct, mss.MSS) + assert isinstance(sct._impl, mss.windows.MSSImplWindows) # Grab the area 1 region1 = {"top": 0, "left": 0, "width": 200, "height": 200} sct.grab(region1) - dib1 = id(sct._dib) + dib1 = id(sct._impl._dib) # Grab the area 2, the cached DIB is used # Same sizes but different positions region2 = {"top": 200, "left": 200, "width": 200, "height": 200} sct.grab(region2) - dib2 = id(sct._dib) + dib2 = id(sct._impl._dib) assert dib1 == dib2 # Grab the area 2 again, the cached DIB is used sct.grab(region2) - assert dib2 == id(sct._dib) + assert dib2 == id(sct._impl._dib) def test_region_not_caching() -> None: """The region to grab is not bad cached previous grab.""" - grab1 = mss.mss() - grab2 = mss.mss() + grab1 = mss.MSS() + grab2 = mss.MSS() - assert isinstance(grab1, mss.windows.MSS) # For Mypy - assert isinstance(grab2, mss.windows.MSS) # For Mypy + assert isinstance(grab1, mss.MSS) # For Mypy + assert isinstance(grab2, mss.MSS) # For Mypy + assert isinstance(grab1._impl, mss.windows.MSSImplWindows) + assert isinstance(grab2._impl, mss.windows.MSSImplWindows) region1 = {"top": 0, "left": 0, "width": 100, "height": 100} region2 = {"top": 0, "left": 0, "width": 50, "height": 1} grab1.grab(region1) - dib1 = id(grab1._dib) + dib1 = id(grab1._impl._dib) grab2.grab(region2) - dib2 = id(grab2._dib) + dib2 = id(grab2._impl._dib) assert dib1 != dib2 # Grab the area 1, is not bad cached DIB previous grab the area 2 grab1.grab(region1) - dib1 = id(grab1._dib) + dib1 = id(grab1._impl._dib) assert dib1 != dib2 def run_child_thread(loops: int) -> None: for _ in range(loops): - with mss.mss() as sct: # New sct for every loop + with mss.MSS() as sct: # New sct for every loop sct.grab(sct.monitors[1]) @@ -81,7 +84,7 @@ def test_thread_safety() -> None: def run_child_thread_bbox(loops: int, bbox: tuple[int, int, int, int]) -> None: - with mss.mss() as sct: # One sct for all loops + with mss.MSS() as sct: # One sct for all loops for _ in range(loops): sct.grab(bbox) diff --git a/src/tests/test_xcb.py b/src/tests/test_xcb.py index f51a9206..4e3db05d 100644 --- a/src/tests/test_xcb.py +++ b/src/tests/test_xcb.py @@ -22,6 +22,7 @@ import pytest +from mss import MSS from mss.exception import ScreenShotError from mss.linux import base, xcb, xgetimage from mss.linux.xcbhelpers import ( @@ -304,9 +305,10 @@ def test_atom_cache_lifecycle() -> None: def test_xgetimage_visual_validation_accepts_default_setup(visual_validation_env: _VisualValidationHarness) -> None: visual_validation_env.reset() - mss_instance = xgetimage.MSS() + mss_instance = MSS(backend="xgetimage") try: - assert isinstance(mss_instance, xgetimage.MSS) + assert isinstance(mss_instance, MSS) + assert isinstance(mss_instance._impl, xgetimage.MSSImplXGetImage) finally: mss_instance.close() @@ -332,4 +334,4 @@ def test_xgetimage_visual_validation_failures( ) -> None: mutator(visual_validation_env) with pytest.raises(ScreenShotError, match=message): - xgetimage.MSS() + MSS(backend="xgetimage") diff --git a/src/tests/third_party/test_numpy.py b/src/tests/third_party/test_numpy.py index 487a61b3..c06e6180 100644 --- a/src/tests/third_party/test_numpy.py +++ b/src/tests/third_party/test_numpy.py @@ -6,12 +6,12 @@ import pytest -from mss.base import MSSBase +from mss import MSS np = pytest.importorskip("numpy", reason="Numpy module not available.") -def test_numpy(mss_impl: Callable[..., MSSBase]) -> None: +def test_numpy(mss_impl: Callable[..., MSS]) -> None: box = {"top": 0, "left": 0, "width": 10, "height": 10} with mss_impl() as sct: img = np.array(sct.grab(box)) diff --git a/src/tests/third_party/test_pil.py b/src/tests/third_party/test_pil.py index 99ea4ba5..274fd049 100644 --- a/src/tests/third_party/test_pil.py +++ b/src/tests/third_party/test_pil.py @@ -8,12 +8,12 @@ import pytest -from mss.base import MSSBase +from mss import MSS Image = pytest.importorskip("PIL.Image", reason="PIL module not available.") -def test_pil(mss_impl: Callable[..., MSSBase]) -> None: +def test_pil(mss_impl: Callable[..., MSS]) -> None: width, height = 16, 16 box = {"top": 0, "left": 0, "width": width, "height": height} with mss_impl() as sct: @@ -31,7 +31,7 @@ def test_pil(mss_impl: Callable[..., MSSBase]) -> None: assert output.is_file() -def test_pil_bgra(mss_impl: Callable[..., MSSBase]) -> None: +def test_pil_bgra(mss_impl: Callable[..., MSS]) -> None: width, height = 16, 16 box = {"top": 0, "left": 0, "width": width, "height": height} with mss_impl() as sct: @@ -49,7 +49,7 @@ def test_pil_bgra(mss_impl: Callable[..., MSSBase]) -> None: assert output.is_file() -def test_pil_not_16_rounded(mss_impl: Callable[..., MSSBase]) -> None: +def test_pil_not_16_rounded(mss_impl: Callable[..., MSS]) -> None: width, height = 10, 10 box = {"top": 0, "left": 0, "width": width, "height": height} with mss_impl() as sct: From 0d84b93faa6ba2e8dfc37a0536404f4c3935c341 Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Tue, 31 Mar 2026 00:43:25 -0700 Subject: [PATCH 2/9] Add CHANGELOG / CHANGES entries --- CHANGELOG.md | 1 + CHANGES.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ad7da3..90bc697f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ See Git commit messages for full history. ## v10.2.0.dev0 (2026-xx-xx) +- Introduce a new API, mss.MSS, that we can keep stable as we continue to improve MSS. The previous documented API is deprecated, but still available, in 10.2. Some parts of the existing API (such as certain type names and internal variables) may be removed in 11.0, but the most important parts will remain available for as long as can be reasonably supported. (#486, #494) - Add `is_primary`, `name`, and `unique_id` keys to Monitor dicts for primary monitor detection, device names, and stable per-monitor identification (#153) - Add `primary_monitor` property to MSS base class for easy access to the primary monitor (#153) - Windows: add primary monitor detection using `GetMonitorInfoW` API (#153) diff --git a/CHANGES.md b/CHANGES.md index 1eac3393..9fb44b85 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## 10.2.0 (2026-xx-xx) ### base.py +- (This affected almost every file in some respect, but base.py was the most affected.) Introduced a new API, mss.MSS. This class can be used instead of the previous various MSSBase subclasses, so that the user can work with a consistent class regardless of implementation details. The methods implemented by MSSBase subclasses were renamed, and moved to MSSImplementation subclasses. The mss.MSS class now holds an MSSImplementation instance as an instance variable. (#486, #494) - Added `primary_monitor` property to return the primary monitor (or first monitor as fallback). ### models.py From d18fb9c77f78fbdd598841320aad9919784fdbfb Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Mon, 30 Mar 2026 21:24:31 -0700 Subject: [PATCH 3/9] Allow and test all kwargs on all platforms. The mss_impl fixture would add an implicit display= argument, regardless of platform. The code at that time would ignore it, but we should be (and in the previous commit, were) more strict. Change mss_impl to only use display= if appropriate, so we can be more strict in the future. In 10.1, these were allowed at all times, and ignored if the platform didn't use them. Emulate this behavior in mss.MSS (and mss.mss), with DeprecationWarnings, and test. --- src/mss/base.py | 17 +++++++++++++++++ src/tests/conftest.py | 10 +++++++++- src/tests/test_compat_10_1.py | 34 +++++++++++++++++++++------------- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/mss/base.py b/src/mss/base.py index 1156c9e4..8bfa9587 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -4,6 +4,7 @@ from __future__ import annotations import platform +import warnings from abc import ABC, abstractmethod from datetime import datetime from threading import Lock @@ -173,6 +174,22 @@ def __init__( compression_level: int = 6, **kwargs: Any, ) -> None: + # TODO(jholveck): #493 Accept platform-specific kwargs on all platforms for migration ease. Foreign kwargs + # are silently stripped with a warning. + platform_only: dict[str, str] = { + "display": "linux", + "max_displays": "darwin", + } + os_ = platform.system().lower() + for kwarg_name, target_platform in platform_only.items(): + if kwarg_name in kwargs and os_ != target_platform: + kwargs.pop(kwarg_name) + warnings.warn( + f"{kwarg_name} is only used on {target_platform}. This will be an error in the future.", + DeprecationWarning, + stacklevel=2, + ) + self._impl: MSSImplementation = _choose_impl( backend=backend, **kwargs, diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 6440bff5..7eaa5990 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -7,6 +7,7 @@ from hashlib import sha256 from pathlib import Path from platform import system +from typing import Any from zipfile import ZipFile import pytest @@ -78,7 +79,14 @@ def backend(request: pytest.FixtureRequest) -> str: def mss_impl(backend: str) -> Callable[..., MSS]: # We can't just use partial here, since it will read $DISPLAY at the wrong time. This can cause problems, # depending on just how the fixtures get run. - return lambda *args, **kwargs: MSS(*args, display=os.getenv("DISPLAY"), backend=backend, **kwargs) + def impl(*args: Any, **kwargs: Any) -> MSS: + # I'm not really sure if adding an explicit display is needed anymore. It was in a lot of existing code that + # mss_impl replaced, but it should now be the default at this point. I'll have to investigate. + if system() == "Linux": + kwargs = {"display": os.getenv("DISPLAY")} | kwargs + return MSS(*args, backend=backend, **kwargs) + + return impl @pytest.fixture(autouse=True, scope="session") diff --git a/src/tests/test_compat_10_1.py b/src/tests/test_compat_10_1.py index 57dec107..d5a9f052 100644 --- a/src/tests/test_compat_10_1.py +++ b/src/tests/test_compat_10_1.py @@ -144,21 +144,29 @@ def test_factory_and_platform_constructor_are_compatible_types() -> None: def test_deprecated_factory_accepts_documented_kwargs() -> None: - os_ = platform.system().lower() - kwargs: dict[str, object] = {"compression_level": 1, "with_cursor": True} + """Verify that kwargs are accepted, even if not relevant. + + All 10.1-documented kwargs were accepted on every platform, even + if only meaningful on one. Verify that still works via the + deprecated factory. + """ + kwargs = { + "compression_level": 1, + "with_cursor": True, + "max_displays": 1, + "display": getenv("DISPLAY"), # None on non-Linux + } + + with pytest.warns(DeprecationWarning) as caught: # noqa: PT030 (we test the contents below) + context = mss.mss(**kwargs) - if os_ == "linux": - display = getenv("DISPLAY") - assert display - kwargs["display"] = display - elif os_ == "darwin": - kwargs["max_displays"] = 1 - elif os_ != "windows": - msg = f"Unsupported platform for compatibility test: {os_!r}" - raise AssertionError(msg) + expected_messages = {"mss.mss is deprecated", "is only used on"} - with pytest.warns(DeprecationWarning, match=r"^mss\.mss is deprecated"): - context = mss.mss(**kwargs) + assert any("mss.mss is deprecated" in str(w.message) for w in caught) + assert any("is only used on" in str(w.message) for w in caught) + assert all(any(expected in str(w.message) for expected in expected_messages) for w in caught), ( + f"Unexpected warnings: {[str(w.message) for w in caught]}" + ) with context as sct: assert isinstance(sct, MSSBase) From cf690d743b804326c93af0cdf27e3abf2114c8c6 Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Tue, 31 Mar 2026 01:26:03 -0700 Subject: [PATCH 4/9] Don't explicitly pass display in one of the tests. I'm pretty sure it's unnecessary there. Not sure why it was being done. --- src/tests/test_implementation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py index a677dfde..62d3840d 100644 --- a/src/tests/test_implementation.py +++ b/src/tests/test_implementation.py @@ -4,7 +4,6 @@ from __future__ import annotations -import os import platform import sys import threading @@ -144,7 +143,7 @@ def test_custom_output_pattern(self, with_cursor: bool, capsys: pytest.CaptureFi for opt in ("-o", "--out"): self._run_main(with_cursor, opt, fmt) captured = capsys.readouterr() - with mss.MSS(display=os.getenv("DISPLAY")) as sct: + with mss.MSS() as sct: for mon, (monitor, line) in enumerate(zip(sct.monitors[1:], captured.out.splitlines()), 1): filename = Path(fmt.format(mon=mon, **monitor)) assert line.endswith(filename.name) From 6f7d4cb6061f3c5895b7c9a5620e63b9c0b5313d Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Tue, 31 Mar 2026 15:53:07 -0700 Subject: [PATCH 5/9] Comment fixes --- src/mss/darwin.py | 2 +- src/mss/linux/base.py | 9 ++++++--- src/mss/linux/xlib.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/mss/darwin.py b/src/mss/darwin.py index 8f55ef9b..c95949bd 100644 --- a/src/mss/darwin.py +++ b/src/mss/darwin.py @@ -175,7 +175,7 @@ def _set_cfunctions(self) -> None: cfactory(attrs[attr], func, argtypes, restype) def monitors(self) -> Monitors: - """Get positions of monitors. It will populate self._monitors.""" + """Get positions of monitors.""" int_ = int core = self.core diff --git a/src/mss/linux/base.py b/src/mss/linux/base.py index 0bde2bab..4593205b 100644 --- a/src/mss/linux/base.py +++ b/src/mss/linux/base.py @@ -56,6 +56,8 @@ def __init__(self, /, display: str | bytes | None = None, **kwargs: Any) -> None # Get the connection setup information that was included when we connected. xcb_setup = xcb.get_setup(self.conn) screens = xcb.setup_roots(xcb_setup) + # pref_screen_num is the screen object corresponding to the screen number, e.g., 1 if DISPLAY=":0.1". It's + # almost always the only screen (screen 0); nobody uses separate screens (in the X sense) anymore. self.pref_screen = screens[pref_screen_num] self.root = self.drawable = self.pref_screen.root @@ -136,9 +138,10 @@ def close(self) -> None: def monitors(self) -> Monitors: """Populate monitor geometry information. - Detects and appends monitor rectangles to ``self._monitors``. The first - entry represents the entire X11 root screen; subsequent entries, when - available, represent individual monitors reported by XRandR. + Detects and returns monitor rectangles. The first entry + represents the entire X11 root screen; subsequent entries, + when available, represent individual monitors reported by + XRandR. """ if self.conn is None: msg = "Cannot identify monitors while the connection is closed" diff --git a/src/mss/linux/xlib.py b/src/mss/linux/xlib.py index a92b19f9..bd95049c 100644 --- a/src/mss/linux/xlib.py +++ b/src/mss/linux/xlib.py @@ -527,7 +527,7 @@ def _set_cfunctions(self) -> None: cfactory(attrs[attr], func, argtypes, restype, errcheck=errcheck) def monitors(self) -> Monitors: - """Get positions of monitors. It will populate self._monitors.""" + """Get positions of monitors.""" display = self._handles.display int_ = int xrandr = self.xrandr From c6f31c3964b8966c560c1c2c7ee435f04d215661 Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Tue, 31 Mar 2026 16:51:40 -0700 Subject: [PATCH 6/9] List the keyword args for mss.MSS explicitly, instead of using **kwargs. This lets IDEs and code checkers know what is acceptable. This lets them autocomplete, and identify incorrect options in user code. --- src/mss/base.py | 81 +++++++++++++++++++++++++---------- src/mss/darwin.py | 5 +-- src/mss/linux/base.py | 4 +- src/mss/linux/xshmgetimage.py | 4 +- src/mss/windows.py | 5 +-- src/tests/test_compat_10_1.py | 13 ++---- 6 files changed, 71 insertions(+), 41 deletions(-) diff --git a/src/mss/base.py b/src/mss/base.py index 8bfa9587..f2332850 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -49,6 +49,21 @@ OPAQUE = 255 +# A sentinel value to indicate that a parameter was not passed, as opposed to being passed with a value of None. This +# is used in the MSS constructor to distinguish between the user not passing a parameter, and the user explicitly +# passing None (which is the default for some parameters). This allows us to preserve the existing behavior of ignoring +# certain parameters on certain platforms, while still allowing users to explicitly set those parameters on platforms +# where they are supported. +class _PlatformSpecific: + def __init__(self, sphinx_repr: Any) -> None: + self.sphinx_repr = str(sphinx_repr) + + def __repr__(self) -> str: + # This is used to get Sphinx to show a useful default when it shows the default in the summary, rather than + # an opaque object. + return self.sphinx_repr + + __all__ = () @@ -64,14 +79,12 @@ class MSSImplementation(ABC): with_cursor: bool def __init__(self, /, *, with_cursor: bool = False) -> None: - # We put with_cursor on the MSSImplementation because the Xlib - # backend will turn it off if the library isn't installed. - # (It's not a separate library under XCB.) So, we need to let - # the backend mutate it. - - # We should remove this expectation in 11.0. It seems - # unlikely to be practically useful, Xlib is legacy, and just - # complicates things. + # We put with_cursor on the MSSImplementation because the Xlib backend will turn it off if the library isn't + # installed. (It's not a separate library under XCB.) So, we need to let the backend mutate it. Note that + # the other platforms don't support with_cursor, and don't pass it to us. + # + # TODO(jholveck): #493 We should remove this expectation in 11.0. It seems unlikely to be practically useful, + # Xlib is legacy, and just complicates things. self.with_cursor = with_cursor @abstractmethod @@ -156,7 +169,8 @@ class MSS: :param backend: Backend selector, for platforms with multiple backends. :param compression_level: PNG compression level. - :param with_cursor: Include the mouse cursor in screenshots. + :param with_cursor: Include the mouse cursor in screenshots (GNU/Linux only) + :type display: bool, optional (default False) :param display: X11 display name (GNU/Linux only). :type display: bytes | str, optional (default :envvar:`$DISPLAY`) :param max_displays: Maximum number of displays to enumerate (macOS only). @@ -164,35 +178,58 @@ class MSS: .. versionadded:: 8.0.0 ``compression_level``, ``display``, ``max_displays``, and ``with_cursor`` keyword arguments. + + .. versionadded:: 10.2.0 + ``backend`` keyword argument. """ + # We want to: + # * Let Sphinx, IDEs, and code-checkers know all the possible kwargs. + # * Know if a user explicitly passed an unsupported platform-dependent keyword, so we can warn. + # * Show a meaningful default in the Sphinx doc's summary string + # + # To accomplish this: + # * We list the possibilities explicitly in the __init__ kwargs. + # * We use a sentinel value, so we can tell whether or not the user actually gave us a value. + # * We represent the "default value" sentinel object with something different, so Sphinx formats it usefully. + _PD_WITH_CURSOR = _PlatformSpecific(False) # noqa: FBT003 + _PD_DISPLAY = _PlatformSpecific(None) + _PD_MAX_DISPLAYS = _PlatformSpecific(32) + def __init__( self, /, *, backend: str = "default", compression_level: int = 6, - **kwargs: Any, + with_cursor: bool | _PlatformSpecific = _PD_WITH_CURSOR, + display: bytes | str | None | _PlatformSpecific = _PD_DISPLAY, + max_displays: int | _PlatformSpecific = _PD_MAX_DISPLAYS, ) -> None: - # TODO(jholveck): #493 Accept platform-specific kwargs on all platforms for migration ease. Foreign kwargs - # are silently stripped with a warning. - platform_only: dict[str, str] = { - "display": "linux", - "max_displays": "darwin", - } - os_ = platform.system().lower() - for kwarg_name, target_platform in platform_only.items(): - if kwarg_name in kwargs and os_ != target_platform: - kwargs.pop(kwarg_name) + impl_kwargs = {} + + system = platform.system().lower() + for name, value, supported_platform in [ + ("with_cursor", with_cursor, "Linux"), + ("display", display, "Linux"), + ("max_displays", max_displays, "Darwin"), + ]: + if isinstance(value, _PlatformSpecific): + continue + if system != supported_platform.lower(): + # TODO(jholveck): #493 Accept platform-specific kwargs on all platforms for migration ease. Foreign + # kwargs are silently stripped with a warning. warnings.warn( - f"{kwarg_name} is only used on {target_platform}. This will be an error in the future.", + f"{name} is only available on {supported_platform}. This will be an error in the future.", DeprecationWarning, stacklevel=2, ) + else: + impl_kwargs[name] = value self._impl: MSSImplementation = _choose_impl( backend=backend, - **kwargs, + **impl_kwargs, ) # The cls_image is only used atomically, so does not require locking. diff --git a/src/mss/darwin.py b/src/mss/darwin.py index c95949bd..62f2d0e6 100644 --- a/src/mss/darwin.py +++ b/src/mss/darwin.py @@ -137,9 +137,8 @@ class MSSImplDarwin(MSSImplementation): __slots__ = {"core", "max_displays"} - def __init__(self, backend: str = "default", max_displays: int = 32, **kwargs: Any) -> None: - kwargs.pop("with_cursor", None) - super().__init__(with_cursor=False, **kwargs) + def __init__(self, *, backend: str = "default", max_displays: int = 32) -> None: + super().__init__() if backend != "default": msg = 'The only valid backend on this platform is "default".' diff --git a/src/mss/linux/base.py b/src/mss/linux/base.py index 4593205b..df702aed 100644 --- a/src/mss/linux/base.py +++ b/src/mss/linux/base.py @@ -42,8 +42,8 @@ class MSSImplXCBBase(MSSImplementation): Lists other parameters. """ - def __init__(self, /, display: str | bytes | None = None, **kwargs: Any) -> None: # noqa: PLR0912 - super().__init__(**kwargs) + def __init__(self, *, display: str | bytes | None = None, with_cursor: bool = False) -> None: # noqa: PLR0912 + super().__init__(with_cursor=with_cursor) if not display: display = None diff --git a/src/mss/linux/xshmgetimage.py b/src/mss/linux/xshmgetimage.py index 68b8cdfd..0cb39601 100644 --- a/src/mss/linux/xshmgetimage.py +++ b/src/mss/linux/xshmgetimage.py @@ -45,8 +45,8 @@ class MSSImplXShmGetImage(MSSImplXCBBase): Lists constructor parameters. """ - def __init__(self, /, **kwargs: Any) -> None: - super().__init__(**kwargs) + def __init__(self, *, display: str | bytes | None = None, with_cursor: bool = False) -> None: + super().__init__(display=display, with_cursor=with_cursor) # These are the objects we need to clean up when we shut down. They are created in _setup_shm. self._memfd: int | None = None diff --git a/src/mss/windows.py b/src/mss/windows.py index d36cdafe..0db68173 100644 --- a/src/mss/windows.py +++ b/src/mss/windows.py @@ -209,9 +209,8 @@ class MSSImplWindows(MSSImplementation): "user32", } - def __init__(self, backend: str = "default", **kwargs: Any) -> None: - kwargs.pop("with_cursor", None) - super().__init__(with_cursor=False, **kwargs) + def __init__(self, *, backend: str = "default") -> None: + super().__init__() if backend != "default": msg = 'The only valid backend on this platform is "default".' diff --git a/src/tests/test_compat_10_1.py b/src/tests/test_compat_10_1.py index d5a9f052..bb23f0bb 100644 --- a/src/tests/test_compat_10_1.py +++ b/src/tests/test_compat_10_1.py @@ -157,16 +157,11 @@ def test_deprecated_factory_accepts_documented_kwargs() -> None: "display": getenv("DISPLAY"), # None on non-Linux } - with pytest.warns(DeprecationWarning) as caught: # noqa: PT030 (we test the contents below) + with ( + pytest.warns(DeprecationWarning, match=r"^mss\.mss is deprecated"), + pytest.warns(DeprecationWarning, match=r"is only available on"), + ): context = mss.mss(**kwargs) - expected_messages = {"mss.mss is deprecated", "is only used on"} - - assert any("mss.mss is deprecated" in str(w.message) for w in caught) - assert any("is only used on" in str(w.message) for w in caught) - assert all(any(expected in str(w.message) for expected in expected_messages) for w in caught), ( - f"Unexpected warnings: {[str(w.message) for w in caught]}" - ) - with context as sct: assert isinstance(sct, MSSBase) From 7e9a69751959ada1133a11b554eb66adb7a9738d Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Tue, 31 Mar 2026 17:05:58 -0700 Subject: [PATCH 7/9] Move the shm_fallback_reason to a more general performance_status property. --- docs/source/examples/linux_xshm_backend.py | 8 ++-- src/mss/base.py | 43 +++++++++------------- src/mss/linux/xshmgetimage.py | 10 ++--- 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/docs/source/examples/linux_xshm_backend.py b/docs/source/examples/linux_xshm_backend.py index 66ad0156..c3691615 100644 --- a/docs/source/examples/linux_xshm_backend.py +++ b/docs/source/examples/linux_xshm_backend.py @@ -10,8 +10,6 @@ screenshot = sct.grab(sct.monitors[1]) print(f"Captured screenshot dimensions: {screenshot.size.width}x{screenshot.size.height}") - print(f"shm_status: {sct.shm_status.name}") - if sct.shm_fallback_reason: - print(f"Falling back to XGetImage because: {sct.shm_fallback_reason}") - else: - print("MIT-SHM capture active.") + print("Did MIT-SHM work:") + for message in sct.performance_status: + print(message) diff --git a/src/mss/base.py b/src/mss/base.py index f2332850..68eec4d6 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -17,7 +17,6 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator - from mss.linux.xshmgetimage import ShmStatus from mss.models import Monitor, Monitors, Size # Prior to 3.11, Python didn't have the Self type. typing_extensions does, but we don't want to depend on it. @@ -74,7 +73,7 @@ class MSSImplementation(ABC): MSS object will hold a lock during these calls. """ - __slots__ = ("with_cursor",) + __slots__ = ("performance_status", "with_cursor") with_cursor: bool @@ -87,6 +86,10 @@ def __init__(self, /, *, with_cursor: bool = False) -> None: # Xlib is legacy, and just complicates things. self.with_cursor = with_cursor + # Any notes the backend needs to give the user for debugging purposes, like why it had to fall back to a + # slower implementation. + self.performance_status: list[str] = [] + @abstractmethod def cursor(self) -> ScreenShot | None: """Retrieve all cursor data. Pixels have to be RGB.""" @@ -169,7 +172,8 @@ class MSS: :param backend: Backend selector, for platforms with multiple backends. :param compression_level: PNG compression level. - :param with_cursor: Include the mouse cursor in screenshots (GNU/Linux only) + :param with_cursor: Include the mouse cursor in screenshots + (GNU/Linux only) :type display: bool, optional (default False) :param display: X11 display name (GNU/Linux only). :type display: bytes | str, optional (default :envvar:`$DISPLAY`) @@ -177,7 +181,8 @@ class MSS: :type max_displays: int, optional (default 32) .. versionadded:: 8.0.0 - ``compression_level``, ``display``, ``max_displays``, and ``with_cursor`` keyword arguments. + ``compression_level``, ``display``, ``max_displays``, and + ``with_cursor`` keyword arguments. .. versionadded:: 10.2.0 ``backend`` keyword argument. @@ -498,35 +503,23 @@ def _cfactory( # max_displays, should probably be removed in 11.0. with_cursor # should probably be moved to MSS instead of MSSImplementation (as # noted there). - # - # The shm_status is mostly a debugging field, and probably should - # be replaced with something different. Ideas include a log - # message, an exception if the user explicitly requested - # xshmgetimage, or a platform-independent message attribute (for - # instance, if Windows has to fall back to GDI). @property - def shm_status(self) -> ShmStatus: - """Whether we can use the MIT-SHM extensions for this connection. + def performance_status(self) -> list[str]: + """Implementation-specific notes that might affect performance. - Availability: GNU/Linux, when using the default XShmGetImage backend. + For instance, on GNU/Linux, when using the default XShmGetImage + backend, this will include a note if the MIT-SHM extension is + not usable. - This will not be ``AVAILABLE`` until at least one capture has succeeded. - It may be set to ``UNAVAILABLE`` sooner. - - .. versionadded:: 10.2.0 - """ - return self._impl.shm_status # type: ignore[attr-defined] - - @property - def shm_fallback_reason(self) -> str | None: - """If MIT-SHM is unavailable, the reason why (for debugging purposes). + This may not be ready until one screenshot has been taken. - Availability: GNU/Linux, when using the default XShmGetImage backend. + This is meant only for debugging purposes; the contents are + subject to change at any time. .. versionadded:: 10.2.0 """ - return self._impl.shm_fallback_reason # type: ignore[attr-defined] + return self._impl.performance_status @property def max_displays(self) -> int: diff --git a/src/mss/linux/xshmgetimage.py b/src/mss/linux/xshmgetimage.py index 0cb39601..b7cc3646 100644 --- a/src/mss/linux/xshmgetimage.py +++ b/src/mss/linux/xshmgetimage.py @@ -5,7 +5,7 @@ This implementation prefers shared-memory captures for performance and will fall back to XGetImage when the MIT-SHM extension is unavailable or fails at -runtime. The fallback reason is exposed via ``shm_fallback_reason`` to aid +runtime. The fallback reason is exposed via ``performance_status`` to aid debugging. .. versionadded:: 10.2.0 @@ -61,8 +61,6 @@ def __init__(self, *, display: str | bytes | None = None, with_cursor: bool = Fa #: This will not be ``AVAILABLE`` until at least one capture has succeeded. #: It may be set to ``UNAVAILABLE`` sooner. self.shm_status: ShmStatus = self._setup_shm() - #: If MIT-SHM is unavailable, the reason why (for debugging purposes). - self.shm_fallback_reason: str | None = None def _shm_report_issue(self, msg: str, *args: Any) -> None: """Debugging hook for troubleshooting MIT-SHM issues. @@ -73,7 +71,7 @@ def _shm_report_issue(self, msg: str, *args: Any) -> None: full_msg = msg if args: full_msg += " | " + ", ".join(str(arg) for arg in args) - self.shm_fallback_reason = full_msg + self.performance_status.append(full_msg) def _setup_shm(self) -> ShmStatus: # noqa: PLR0911 assert self.conn is not None # noqa: S101 @@ -200,7 +198,9 @@ def grab(self, monitor: Monitor) -> bytearray: # The usual path is just the next few lines. try: rv = self._grab_xshmgetimage(monitor) - self.shm_status = ShmStatus.AVAILABLE + if self.shm_status != ShmStatus.AVAILABLE: + self.shm_status = ShmStatus.AVAILABLE + self.performance_status.append("MIT-SHM is working correctly.") except XProtoError as e: if self.shm_status != ShmStatus.UNKNOWN: # We know XShmGetImage works, because it worked earlier. Reraise the error. From f0e86523ac04da1591b7965f4226737a98f326ee Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Tue, 31 Mar 2026 17:29:39 -0700 Subject: [PATCH 8/9] Warn and ignore for --with-cursor in command line on non-Linux. --- src/mss/__main__.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/mss/__main__.py b/src/mss/__main__.py index 8263d2b2..1ba24a8d 100644 --- a/src/mss/__main__.py +++ b/src/mss/__main__.py @@ -29,7 +29,7 @@ def _backend_cli_choices() -> list[str]: return ["default"] -def main(*args: str) -> int: +def main(*args: str) -> int: # noqa: PLR0912 """Main logic.""" backend_choices = _backend_cli_choices() @@ -51,7 +51,7 @@ def main(*args: str) -> int: ) cli_args.add_argument("-m", "--monitor", default=0, type=int, help="the monitor to screenshot") cli_args.add_argument("-o", "--output", default="monitor-{mon}.png", help="the output file name") - cli_args.add_argument("--with-cursor", default=False, action="store_true", help="include the cursor") + cli_args.add_argument("--with-cursor", action="store_true", help="include the cursor") cli_args.add_argument( "-q", "--quiet", @@ -72,7 +72,7 @@ def main(*args: str) -> int: cli_args.print_usage(sys.stderr) print(f"{cli_args.prog}: error: {e}", file=sys.stderr) return 2 - kwargs = {"mon": options.monitor, "output": options.output} + grab_kwargs = {"mon": options.monitor, "output": options.output} if options.coordinates: try: top, left, width, height = options.coordinates.split(",") @@ -80,25 +80,34 @@ def main(*args: str) -> int: print("Coordinates syntax: top, left, width, height") return 2 - kwargs["mon"] = { + grab_kwargs["mon"] = { "top": int(top), "left": int(left), "width": int(width), "height": int(height), } if options.output == "monitor-{mon}.png": - kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png" + grab_kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png" + + if options.with_cursor is not None and platform.system().lower() != "linux": + if not options.quiet: + print("[WARNING] --with-cursor is only supported on Linux; ignoring.") + options.with_cursor = None + + mss_kwargs = {"backend": options.backend} + if options.with_cursor is not None: + mss_kwargs["with_cursor"] = options.with_cursor try: - with MSS(with_cursor=options.with_cursor, backend=options.backend) as sct: + with MSS(**mss_kwargs) as sct: if options.coordinates: - output = kwargs["output"].format(**kwargs["mon"]) - sct_img = sct.grab(kwargs["mon"]) + output = grab_kwargs["output"].format(**grab_kwargs["mon"]) + sct_img = sct.grab(grab_kwargs["mon"]) to_png(sct_img.rgb, sct_img.size, level=options.level, output=output) if not options.quiet: print(os.path.realpath(output)) else: - for file_name in sct.save(**kwargs): + for file_name in sct.save(**grab_kwargs): if not options.quiet: print(os.path.realpath(file_name)) return 0 From cc27f3441a9c01df391e71e466ac1d26dbedb64c Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Tue, 31 Mar 2026 17:37:13 -0700 Subject: [PATCH 9/9] Put the --with-cursor warning on stderr, not stdout --- src/mss/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mss/__main__.py b/src/mss/__main__.py index 1ba24a8d..4641b2ae 100644 --- a/src/mss/__main__.py +++ b/src/mss/__main__.py @@ -51,7 +51,7 @@ def main(*args: str) -> int: # noqa: PLR0912 ) cli_args.add_argument("-m", "--monitor", default=0, type=int, help="the monitor to screenshot") cli_args.add_argument("-o", "--output", default="monitor-{mon}.png", help="the output file name") - cli_args.add_argument("--with-cursor", action="store_true", help="include the cursor") + cli_args.add_argument("--with-cursor", default=None, action="store_true", help="include the cursor") cli_args.add_argument( "-q", "--quiet", @@ -91,7 +91,7 @@ def main(*args: str) -> int: # noqa: PLR0912 if options.with_cursor is not None and platform.system().lower() != "linux": if not options.quiet: - print("[WARNING] --with-cursor is only supported on Linux; ignoring.") + print("[WARNING] --with-cursor is only supported on Linux; ignoring.", file=sys.stderr) options.with_cursor = None mss_kwargs = {"backend": options.backend}