diff --git a/AGENTS.md b/AGENTS.md index ef200444..6e0d4ded 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,14 @@ Run tests: python -m pytest ``` +When adding, removing, or renaming a packaged file, update the explicit expected file list in +`src/tests/test_setup.py::test_sdist` and, for files included in binary distributions, in +`src/tests/test_setup.py::test_wheel`. Run the focused packaging tests: + +```shell +python -m pytest src/tests/test_setup.py::test_sdist src/tests/test_setup.py::test_wheel +``` + On headless GNU/Linux environments, run tests with a virtual display: ```shell diff --git a/demos/video-capture-simple.py b/demos/video-capture-simple.py index 22c5c0df..efe846b9 100755 --- a/demos/video-capture-simple.py +++ b/demos/video-capture-simple.py @@ -72,17 +72,21 @@ def main() -> None: monitor = sct.monitors[1] # Because of how H.264 video stores color information, libx264 requires the video size to be a multiple of - # two. - monitor["width"] = (monitor["width"] // 2) * 2 - monitor["height"] = (monitor["height"] // 2) * 2 + # two. Keep the monitor description unchanged and capture a slightly smaller region when necessary. + capture_region = { + "left": monitor.left, + "top": monitor.top, + "width": (monitor.width // 2) * 2, + "height": (monitor.height // 2) * 2, + } with av.open(FILENAME, "w") as avmux: # The "avmux" object we get back from "av.open" represents the MP4 file. That's a container that holds # the video, as well as possibly audio and more. These are each called "streams". We only create one # stream here, since we're just recording video. video_stream = avmux.add_stream(CODEC, rate=FPS, options=CODEC_OPTIONS) - video_stream.width = monitor["width"] - video_stream.height = monitor["height"] + video_stream.width = capture_region["width"] + video_stream.height = capture_region["height"] # There are more options you can set on the video stream; the full demo uses some of those. # Count how many frames we're capturing, so we can log the FPS later. @@ -121,7 +125,7 @@ def main() -> None: print(".", end="", flush=True) # Grab a screenshot. - screenshot = sct.grab(monitor) + screenshot = sct.grab(capture_region) frame_count += 1 # There are a few ways to get the screenshot into a VideoFrame. The highest-performance way isn't diff --git a/demos/video-capture.py b/demos/video-capture.py index 66f068cc..f2fe3d13 100755 --- a/demos/video-capture.py +++ b/demos/video-capture.py @@ -122,12 +122,11 @@ # Install the necessary libraries with "pip install av mss numpy si-prefix". import av -import numpy as np from si_prefix import si_format import mss -from common.pipeline import Mailbox, PipelineStage +from common.pipeline import Mailbox, PipelineStage # These are the options you'd give to ffmpeg that it sends to the video codec. Because ffmpeg and PyAV both use the # libav libraries, you can get the list of available flags with `ffmpeg -help encoder=libx264`, or whatever encoder @@ -165,7 +164,7 @@ def video_capture( fps: int, sct: mss.MSS, - monitor: mss.models.Monitor, + capture_region: dict[str, int], shutdown_requested: Event, ) -> Generator[tuple[mss.screenshot.ScreenShot, float], None, None]: # Keep track of the time when we want to get the next frame. We limit the frame time this way instead of sleeping @@ -183,7 +182,7 @@ def video_capture( time.sleep(next_frame_at - now) # Capture a frame, and send it to the next processing stage. - screenshot = sct.grab(monitor) + screenshot = sct.grab(capture_region) yield screenshot, now # We try to keep the capture rate at the desired fps on average. If we can't quite keep up for a moment (such @@ -436,7 +435,7 @@ def main() -> None: with mss.MSS() as sct: if args.region: left, top, right, bottom = args.region - monitor = { + capture_region = { "left": left, "top": top, "width": right - left, @@ -444,6 +443,7 @@ def main() -> None: } else: monitor = sct.monitors[args.monitor] + capture_region = monitor.as_capture_region() # Some codecs, such as libx264, require the region to be a multiple of 2, to get the chroma subsampling right. # Others, such as h264_nvenc, do not; they'll pad to get the subsampling region, and add flags to the stream @@ -453,8 +453,8 @@ def main() -> None: # it (at least, when using 4:2:0 subsampling). region_crop_to_multiple_of_two = codec in {"libx264", "libx265"} if region_crop_to_multiple_of_two: - monitor["width"] = (monitor["width"] // 2) * 2 - monitor["height"] = (monitor["height"] // 2) * 2 + capture_region["width"] = (capture_region["width"] // 2) * 2 + capture_region["height"] = (capture_region["height"] // 2) * 2 # We don't pass the container format to av.open here, so it will choose it based on the extension: .mp4, .mkv, # etc. @@ -493,8 +493,8 @@ def main() -> None: # so some video encoders will tag it as AVCOL_TRC_BT709 (1) instead. video_stream.color_trc = 13 - video_stream.width = monitor["width"] - video_stream.height = monitor["height"] + video_stream.width = capture_region["width"] + video_stream.height = capture_region["height"] # There are multiple time bases in play (stream, codec context, per-frame). Depending on the container # and codec, some of these might be ignored or overridden. We set the desired time base consistently # everywhere, so that the saved timestamps are correct regardless of what format we're saving to. @@ -535,7 +535,7 @@ def main() -> None: video_capture, fps, sct, - monitor, + capture_region, shutdown_requested, ), out_mailbox=mailbox_screenshot, diff --git a/docs/source/examples/custom_cls_image.py b/docs/source/examples/custom_cls_image.py index 2c04a150..789c232b 100644 --- a/docs/source/examples/custom_cls_image.py +++ b/docs/source/examples/custom_cls_image.py @@ -7,7 +7,7 @@ from typing import Any import mss -from mss.models import Monitor +from mss.models import CaptureRegion from mss.screenshot import ScreenShot @@ -17,9 +17,9 @@ class SimpleScreenShot(ScreenShot): or add new methods. """ - def __init__(self, data: bytearray, monitor: Monitor, **_: Any) -> None: + def __init__(self, data: bytearray, region: CaptureRegion, **_: Any) -> None: self.data = data - self.monitor = monitor + self.region = region with mss.MSS() as sct: diff --git a/docs/source/examples/from_pil_tuple.py b/docs/source/examples/from_pil_tuple.py index aa056109..3219254b 100644 --- a/docs/source/examples/from_pil_tuple.py +++ b/docs/source/examples/from_pil_tuple.py @@ -12,8 +12,8 @@ monitor = sct.monitors[1] # Capture a bbox using percent values - left = monitor["left"] + monitor["width"] * 5 // 100 # 5% from the left - top = monitor["top"] + monitor["height"] * 5 // 100 # 5% from the top + left = monitor.left + monitor.width * 5 // 100 # 5% from the left + top = monitor.top + monitor.height * 5 // 100 # 5% from the top right = left + 400 # 400px width lower = top + 400 # 400px height bbox = (left, top, right, lower) diff --git a/docs/source/examples/part_of_screen_monitor_2.py b/docs/source/examples/part_of_screen_monitor_2.py index 082a56f6..c07f00bb 100644 --- a/docs/source/examples/part_of_screen_monitor_2.py +++ b/docs/source/examples/part_of_screen_monitor_2.py @@ -14,8 +14,8 @@ # The screen part to capture monitor = { - "top": mon["top"] + 100, # 100px from the top - "left": mon["left"] + 100, # 100px from the left + "top": mon.top + 100, # 100px from the top + "left": mon.left + 100, # 100px from the left "width": 160, "height": 135, "mon": monitor_number, diff --git a/docs/source/release-history/v10.2.0.md b/docs/source/release-history/v10.2.0.md index 6999d02e..5794fab1 100644 --- a/docs/source/release-history/v10.2.0.md +++ b/docs/source/release-history/v10.2.0.md @@ -260,7 +260,7 @@ In 11.0, monitor dictionaries will become a dedicated **`Monitor` class**. To maintain compatibility: -- dictionary-style access will continue to work +- string-key access will temporarily continue to work ```python monitor["left"] @@ -269,6 +269,14 @@ monitor["top"] - `grab()` will continue accepting dictionaries +The compatibility access does not make `Monitor` a complete mapping. Migrate dictionary methods, membership tests, and +unpacking to attribute access: + +```python +monitor.left +monitor.top +``` + If you use type annotations, you can switch to the provided `Monitor` type: ```python diff --git a/docs/source/release-history/v11.0.0.md b/docs/source/release-history/v11.0.0.md index 91c1f1d2..abb3c0d6 100644 --- a/docs/source/release-history/v11.0.0.md +++ b/docs/source/release-history/v11.0.0.md @@ -22,6 +22,28 @@ The {py:attr}`mss.ScreenShot.bgra` and {py:attr}`mss.ScreenShot.rgb` properties {py:class}`memoryview` objects, rather than {py:class}`bytes` or {py:class}`bytearray` objects. For practical use cases, this should not be noticeable. This change allows faster access to screenshot data, with fewer memory copies. +#### Immutable monitor objects + +{py:attr}`mss.MSS.monitors` now returns frozen, slotted {py:class}`mss.models.Monitor` objects instead of dictionaries. +Monitor entries are cached display-configuration snapshots; keeping them immutable prevents application code from +accidentally changing the geometry or metadata held by an {py:class}`mss.MSS` instance. +Use attributes for geometry and metadata: + +```python +monitor = sct.monitors[1] +print(monitor.left, monitor.top, monitor.width, monitor.height) +print(monitor.is_primary, monitor.name, monitor.unique_id, monitor.output) +region = monitor.as_capture_region() +``` + +The four geometry attributes are always present. Metadata attributes are `None` when unavailable. String-key access +such as `monitor["width"]` remains temporarily available for migration, but `Monitor` is not a mapping: dictionary +methods, membership tests, and `**monitor` unpacking are not supported. +Use {py:meth}`mss.models.Monitor.as_capture_region` when a mutable capture-region dictionary is needed. + +Existing regions can still be passed to {py:meth}`mss.MSS.grab` as dictionaries or PIL-style +`(left, top, right, bottom)` tuples. + ### Python 3.9 EOL Python 3.9 reached [end-of-life](https://devguide.python.org/developer-workflow/development-cycle/index.html#end-of-life-branches) on [October 31, 2025](https://devguide.python.org/versions/). It is no longer receiving any updates, even security updates. diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 216938f2..95b50853 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -52,6 +52,19 @@ list of all the monitors, starting from index 1, as well as the full virtual scr The primary monitor, the one that holds the taskbar or similar system UI, is available as :py:attr:`mss.MSS.primary_monitor`. +Each entry is an immutable :py:class:`mss.models.Monitor`. Its geometry is available through ``left``, ``top``, +``width``, and ``height`` attributes. The ``is_primary``, ``name``, ``unique_id``, and ``output`` metadata attributes +are ``None`` when unavailable:: + + monitor = sct.monitors[1] + print(monitor.width, monitor.height) + +Call ``monitor.as_capture_region()`` when you need its geometry as a mutable capture-region dictionary. + +For migration from MSS 10, string-key access such as ``monitor["width"]`` is temporarily supported. ``Monitor`` is not +a mapping, so dictionary methods, membership tests, and ``**monitor`` unpacking are not supported. New code should use +attributes. + For capturing a specific region, you can pass :py:meth:`mss.MSS.grab` a dictionary with the keys ``top``, ``left``, ``width``, and ``height``. For instance, to capture a 100x100 pixel region starting at the top-left corner of the screen, you could use :python:`{"top": 0, "left": 0, "width": 100, "height": 100}`. You can also use a PIL-style box, @@ -323,4 +336,4 @@ Or via direct call from Python:: .. versionadded:: 11.0.0 ``--coordinates`` now accepts coordinates in the traditional X11 style (WIDTHxHEIGHT+LEFT+TOP), as well as negative - left or top values (in either style). \ No newline at end of file + left or top values (in either style). diff --git a/src/mss/__main__.py b/src/mss/__main__.py index 958a6a46..d8504103 100644 --- a/src/mss/__main__.py +++ b/src/mss/__main__.py @@ -155,9 +155,9 @@ def _capture_and_save( """Capture screenshots and write output files.""" if coordinates is not None: if coordinates["top"] < 0: - coordinates["top"] = sct.monitors[monitor_index]["height"] + coordinates["top"] + coordinates["top"] = sct.monitors[monitor_index].height + coordinates["top"] if coordinates["left"] < 0: - coordinates["left"] = sct.monitors[monitor_index]["width"] + coordinates["left"] + coordinates["left"] = sct.monitors[monitor_index].width + coordinates["left"] output = output_template.format(**coordinates) sct_img = sct.grab(coordinates) to_png(sct_img.rgb, sct_img.size, level=options.level, output=output) diff --git a/src/mss/base.py b/src/mss/base.py index 09c0a775..c8b06861 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any from mss.exception import ScreenShotError +from mss.models import Monitor from mss.screenshot import ScreenShot from mss.tools import to_png @@ -20,7 +21,7 @@ from typing_extensions import Buffer, Self - from mss.models import Monitor, Monitors, Size + from mss.models import CaptureRegion, Monitors, Size try: from datetime import UTC @@ -89,11 +90,14 @@ def cursor(self) -> ScreenShot | None: """Retrieve all cursor data. Pixels have to be RGB.""" @abstractmethod - def grab(self, monitor: Monitor, /) -> Buffer | tuple[Buffer, 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). + def grab(self, region: CaptureRegion, /) -> Buffer | tuple[Buffer, Size]: + """Retrieve all pixels from a capture region. Pixels have to be RGB. + + Return ``(buffer, size)`` when the pixel dimensions of the returned + buffer differ from the region's width and height. For example, a + Retina display region may be measured in logical points while its + image buffer contains twice as many pixels in each dimension (see + issue #23). """ @abstractmethod @@ -291,7 +295,7 @@ def close(self) -> None: self._impl.close() self._closed = True - def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: + def grab(self, monitor: Monitor | dict[str, Any] | tuple[int, int, int, int], /) -> ScreenShot: """Retrieve screen pixels for a given monitor. Note: ``monitor`` can be a tuple like the one @@ -303,25 +307,34 @@ def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: """ # Convert PIL bbox style if isinstance(monitor, tuple): - monitor = { + region: CaptureRegion = { "left": monitor[0], "top": monitor[1], "width": monitor[2] - monitor[0], "height": monitor[3] - monitor[1], } + elif isinstance(monitor, Monitor): + region = monitor.as_capture_region() + elif isinstance(monitor, dict): + region = { + "left": monitor["left"], + "top": monitor["top"], + "width": monitor["width"], + "height": monitor["height"], + } - if monitor["width"] <= 0 or monitor["height"] <= 0: - msg = f"Region has zero or negative size: {monitor!r}" + if region["width"] <= 0 or region["height"] <= 0: + msg = f"Region has zero or negative size: {region!r}" raise ScreenShotError(msg) with self._lock: - img_data_and_maybe_size = self._impl.grab(monitor) + img_data_and_maybe_size = self._impl.grab(region) 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) + screenshot = self.cls_image(img_data, region, size=size) else: img_data = img_data_and_maybe_size - screenshot = self.cls_image(img_data, monitor) + screenshot = self.cls_image(img_data, region) if self._impl.with_cursor and (cursor := self._impl.cursor()): return self._merge(screenshot, cursor) return screenshot @@ -335,19 +348,19 @@ def monitors(self) -> Monitors: This method has to fill ``self._monitors`` with all information and use it as a cache: - - ``self._monitors[0]`` is a dict of all monitors together - - ``self._monitors[N]`` is a dict of the monitor N (with N > 0) + - ``self._monitors[0]`` is all monitors together + - ``self._monitors[N]`` is monitor N (with N > 0) - Each monitor is a dict with: + Each :class:`mss.models.Monitor` has: - ``left``: the x-coordinate of the upper-left corner - ``top``: the y-coordinate of the upper-left corner - ``width``: the width - ``height``: the height - - ``is_primary``: (optional) true if this is the primary monitor - - ``name``: (optional) human-readable device name - - ``unique_id``: (optional) platform-specific stable identifier for the monitor - - ``output``: (optional, Linux only) monitor output name, compatible with xrandr + - ``is_primary``: true or false when known, otherwise ``None`` + - ``name``: human-readable device name, or ``None`` + - ``unique_id``: platform-specific stable identifier, or ``None`` + - ``output``: Linux output name compatible with xrandr, or ``None`` """ with self._lock: if self._monitors is None: @@ -376,7 +389,7 @@ def primary_monitor(self) -> Monitor: ( monitor for monitor in monitors[1:] # Skip the "all monitors" entry at index 0 - if monitor.get("is_primary", False) + if monitor.is_primary ), monitors[1], # Fallback to the first monitor if no primary is found ) @@ -396,7 +409,9 @@ def save( grabs monitor ``N``. :param str output: The output filename. Keywords: ``{mon}``, ``{top}``, ``{left}``, ``{width}``, ``{height}``, - ``{date}``. + ``{is_primary}``, ``{name}``, ``{unique_id}``, ``{output}``, + ``{date}``. Optional metadata is formatted as ``None`` when + unavailable. :param typing.Callable callback: Called before saving the screenshot; receives the ``output`` argument. :return: Created file(s). @@ -409,7 +424,18 @@ def save( if mon == 0: # One screenshot by monitor for idx, monitor in enumerate(monitors[1:], 1): - fname = output.format(mon=idx, date=datetime.now(UTC) if "{date" in output else None, **monitor) + fname = output.format( + mon=idx, + date=datetime.now(UTC) if "{date" in output else None, + top=monitor.top, + left=monitor.left, + width=monitor.width, + height=monitor.height, + is_primary=monitor.is_primary, + name=monitor.name, + unique_id=monitor.unique_id, + output=monitor.output, + ) if callable(callback): callback(fname) sct = self.grab(monitor) @@ -425,7 +451,18 @@ def save( msg = f"Monitor {mon!r} does not exist." raise ScreenShotError(msg) from exc - output = output.format(mon=mon, date=datetime.now(UTC) if "{date" in output else None, **monitor) + output = output.format( + mon=mon, + date=datetime.now(UTC) if "{date" in output else None, + top=monitor.top, + left=monitor.left, + width=monitor.width, + height=monitor.height, + is_primary=monitor.is_primary, + name=monitor.name, + unique_id=monitor.unique_id, + output=monitor.output, + ) if callable(callback): callback(output) sct = self.grab(monitor) diff --git a/src/mss/darwin.py b/src/mss/darwin.py index cba36e7a..40f6f614 100644 --- a/src/mss/darwin.py +++ b/src/mss/darwin.py @@ -28,12 +28,13 @@ from mss.base import MSS as _MSS from mss.base import MSSImplementation from mss.exception import ScreenShotError +from mss.models import Monitor from mss.screenshot import Size if TYPE_CHECKING: from typing import Any - from mss.models import CFunctions, Monitor, Monitors + from mss.models import CaptureRegion, CFunctions, Monitors __all__ = ("IMAGE_OPTIONS", "MSS") @@ -184,8 +185,6 @@ def monitors(self) -> Monitors: # We need to update the value with every single monitor found # using CGRectUnion. Else we will end with infinite values. all_monitors = CGRect() - monitors.append({}) - # Each monitor display_count = c_uint32(0) active_displays = (c_uint32 * self.max_displays)() @@ -203,31 +202,34 @@ def monitors(self) -> Monitors: width, height = height, width monitors.append( - { - "left": int_(rect.origin.x), - "top": int_(rect.origin.y), - "width": int_(width), - "height": int_(height), - }, + Monitor( + left=int_(rect.origin.x), + top=int_(rect.origin.y), + width=int_(width), + height=int_(height), + ), ) # Update AiO monitor's values all_monitors = core.CGRectUnion(all_monitors, rect) # Set the AiO monitor's values - 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), - } + monitors.insert( + 0, + Monitor( + left=int_(all_monitors.origin.x), + top=int_(all_monitors.origin.y), + width=int_(all_monitors.size.width), + height=int_(all_monitors.size.height), + ), + ) return monitors - def grab(self, monitor: Monitor, /) -> tuple[bytearray, Size]: - """Retrieve all pixels from a monitor. Pixels have to be RGB.""" + def grab(self, region: CaptureRegion, /) -> tuple[bytearray, Size]: + """Retrieve all pixels from a capture region. Pixels have to be RGB.""" core = self.core - rect = CGRect((monitor["left"], monitor["top"]), (monitor["width"], monitor["height"])) + rect = CGRect((region["left"], region["top"]), (region["width"], region["height"])) image_ref = core.CGWindowListCreateImage(rect, 1, 0, IMAGE_OPTIONS) if not image_ref: diff --git a/src/mss/linux/base.py b/src/mss/linux/base.py index 972c9ce1..33643865 100644 --- a/src/mss/linux/base.py +++ b/src/mss/linux/base.py @@ -1,19 +1,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, TypedDict from urllib.parse import urlencode from mss.base import MSSImplementation from mss.exception import ScreenShotError from mss.linux import xcb from mss.linux.xcb import LIB +from mss.models import Monitor from mss.screenshot import ScreenShot from mss.tools import parse_edid if TYPE_CHECKING: from ctypes import Array - from mss.models import Monitor, Monitors + from mss.models import CaptureRegion, Monitors __all__ = () @@ -25,6 +26,12 @@ ALL_PLANES = 0xFFFFFFFF # XCB doesn't define AllPlanes +class _RandROutputIds(TypedDict, total=False): + name: str + unique_id: str + output: str + + class MSSImplXCBBase(MSSImplementation): """Base class for XCB-based screenshot implementations. @@ -183,12 +190,12 @@ def _root_monitor(self) -> Monitor: raise ScreenShotError(msg) root_geom = xcb.get_geometry(self.conn, self.root) - return { - "left": root_geom.x, - "top": root_geom.y, - "width": root_geom.width, - "height": root_geom.height, - } + return Monitor( + 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: @@ -232,7 +239,7 @@ def _randr_output_ids( timestamp: xcb.Timestamp, edid_atom: xcb.Atom | None, /, - ) -> dict[str, Any]: + ) -> _RandROutputIds: if self.conn is None: msg = "Cannot identify monitors while the connection is closed" raise ScreenShotError(msg) @@ -242,7 +249,7 @@ def _randr_output_ids( msg = "Display configuration changed while detecting monitors." raise ScreenShotError(msg) - rv: dict[str, Any] = {} + rv: _RandROutputIds = {} output_name_arr = xcb.randr_get_output_info_name(output_info) rv["output"] = bytes(output_name_arr).decode("utf_8", errors="replace") @@ -305,24 +312,29 @@ def _monitors_from_randr_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): - monitor = { - "left": randr_monitor.x, - "top": randr_monitor.y, - "width": randr_monitor.width, - "height": randr_monitor.height, - # Under XRandR, it's legal for no monitor to be primary. In this case, case MSSBase.primary_monitor - # will return the first monitor. That said, we note in the dict that we explicitly are told by XRandR - # that all of the monitors are not primary. (This is distinct from the XRandR 1.2 path, which doesn't - # have any information about primary monitors.) - "is_primary": bool(randr_monitor.primary), - } - + output_ids: _RandROutputIds = {} if randr_monitor.nOutput > 0: outputs = xcb.randr_monitor_info_outputs(randr_monitor) chosen_output = self._choose_randr_output(outputs, primary_output) - monitor |= self._randr_output_ids(chosen_output, timestamp, edid_atom) - - monitors.append(monitor) + output_ids = self._randr_output_ids(chosen_output, timestamp, edid_atom) + + monitors.append( + Monitor( + left=randr_monitor.x, + top=randr_monitor.y, + width=randr_monitor.width, + height=randr_monitor.height, + # Under XRandR, it's legal for no monitor to be primary. In + # this case, case MSSBase.primary_monitor will return the + # first monitor. That said, we note in the Monitor that we + # explicitly are told by XRandR that all of the monitors are + # not primary. (This is distinct from the XRandR 1.2 path, + # which doesn't have any information about primary + # monitors.) + is_primary=bool(randr_monitor.primary), + **output_ids, + ), + ) return monitors @@ -352,23 +364,22 @@ def _monitors_from_randr_crtcs( crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, timestamp) if crtc_info.num_outputs == 0: continue - monitor = { - "left": crtc_info.x, - "top": crtc_info.y, - "width": crtc_info.width, - "height": crtc_info.height, - } - outputs = xcb.randr_get_crtc_info_outputs(crtc_info) chosen_output = self._choose_randr_output(outputs, primary_output) - monitor |= self._randr_output_ids(chosen_output, timestamp, edid_atom) + output_ids = self._randr_output_ids(chosen_output, timestamp, edid_atom) # The concept of primary outputs was added in XRandR 1.3. We distinguish between "all the monitors are # not primary" (RRGetOutputPrimary returned XCB_NONE, a valid case) and "we have no way to get - # information about the primary monitor": in the latter case, we don't populate "is_primary". - if primary_output is not None: - monitor["is_primary"] = chosen_output == primary_output - - monitors.append(monitor) + # information about the primary monitor": in the latter case, is_primary is None. + monitors.append( + Monitor( + left=crtc_info.x, + top=crtc_info.y, + width=crtc_info.width, + height=crtc_info.height, + is_primary=chosen_output == primary_output if primary_output is not None else None, + **output_ids, + ), + ) return monitors @@ -410,7 +421,7 @@ def cursor(self) -> ScreenShot: raise ScreenShotError(msg) cursor_img = xcb.xfixes_get_cursor_image(self.conn) - region = { + region: CaptureRegion = { "left": cursor_img.x - cursor_img.xhot, "top": cursor_img.y - cursor_img.yhot, "width": cursor_img.width, @@ -424,13 +435,13 @@ def cursor(self) -> ScreenShot: return ScreenShot(data, region) - def _grab_xgetimage(self, monitor: Monitor, /) -> bytearray: - """Retrieve pixels from a monitor using ``GetImage``. + def _grab_xgetimage(self, region: CaptureRegion, /) -> bytearray: + """Retrieve pixels from a capture region using ``GetImage``. Used by the XGetImage backend and by the XShmGetImage backend in fallback mode. - :param monitor: Monitor rectangle specifying ``left``, ``top``, + :param region: Rectangle specifying ``left``, ``top``, ``width``, and ``height`` to capture. :returns: A screenshot object containing the captured region. """ @@ -443,10 +454,10 @@ def _grab_xgetimage(self, monitor: Monitor, /) -> bytearray: self.conn, xcb.ImageFormat.ZPixmap, self.drawable, - monitor["left"], - monitor["top"], - monitor["width"], - monitor["height"], + region["left"], + region["top"], + region["width"], + region["height"], ALL_PLANES, ) diff --git a/src/mss/linux/xgetimage.py b/src/mss/linux/xgetimage.py index ad28c056..51e7758e 100644 --- a/src/mss/linux/xgetimage.py +++ b/src/mss/linux/xgetimage.py @@ -10,7 +10,7 @@ """ from mss.linux.base import MSSImplXCBBase -from mss.models import Monitor +from mss.models import CaptureRegion __all__ = () @@ -23,6 +23,6 @@ class MSSImplXGetImage(MSSImplXCBBase): Lists constructor parameters. """ - def grab(self, monitor: Monitor) -> bytearray: - """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" - return super()._grab_xgetimage(monitor) + def grab(self, region: CaptureRegion) -> bytearray: + """Retrieve all pixels from a capture region. Pixels have to be RGBX.""" + return super()._grab_xgetimage(region) diff --git a/src/mss/linux/xlib.py b/src/mss/linux/xlib.py index 3bd4a4a8..15622191 100644 --- a/src/mss/linux/xlib.py +++ b/src/mss/linux/xlib.py @@ -37,12 +37,13 @@ from mss.base import MSSImplementation from mss.exception import ScreenShotError +from mss.models import Monitor from mss.screenshot import ScreenShot if TYPE_CHECKING: from threading import Thread - from mss.models import CFunctions, Monitor, Monitors + from mss.models import CaptureRegion, CFunctions, Monitors __all__ = () @@ -544,7 +545,7 @@ def monitors(self) -> Monitors: gwa = XWindowAttributes() self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa)) monitors.append( - {"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)}, + Monitor(left=int_(gwa.x), top=int_(gwa.y), width=int_(gwa.width), height=int_(gwa.height)), ) # Each monitor @@ -567,29 +568,29 @@ def monitors(self) -> Monitors: continue monitors.append( - { - "left": int_(crtc.x), - "top": int_(crtc.y), - "width": int_(crtc.width), - "height": int_(crtc.height), - }, + Monitor( + left=int_(crtc.x), + top=int_(crtc.y), + width=int_(crtc.width), + height=int_(crtc.height), + ), ) xrandr.XRRFreeCrtcInfo(crtc) xrandr.XRRFreeScreenResources(mon) return monitors - def grab(self, monitor: Monitor, /) -> bytearray: - """Retrieve all pixels from a monitor. Pixels have to be RGB.""" + def grab(self, region: CaptureRegion, /) -> bytearray: + """Retrieve all pixels from a capture region. Pixels have to be RGB.""" with _lock: ximage = self.xlib.XGetImage( self._handles.display, self._handles.drawable, - monitor["left"], - monitor["top"], - monitor["width"], - monitor["height"], + region["left"], + region["top"], + region["width"], + region["height"], PLAINMASK, ZPIXMAP, ) @@ -602,7 +603,7 @@ def grab(self, monitor: Monitor, /) -> bytearray: raw_data = cast( ximage.contents.data, - POINTER(c_ubyte * monitor["height"] * monitor["width"] * 4), + POINTER(c_ubyte * region["height"] * region["width"] * 4), ) data = bytearray(raw_data.contents) finally: @@ -625,7 +626,7 @@ def cursor(self) -> ScreenShot | None: raise ScreenShotError(msg) cursor_img: XFixesCursorImage = ximage.contents - region = { + region: CaptureRegion = { "left": cursor_img.x - cursor_img.xhot, "top": cursor_img.y - cursor_img.yhot, "width": cursor_img.width, diff --git a/src/mss/linux/xshmgetimage.py b/src/mss/linux/xshmgetimage.py index b8c7225e..ff4e8002 100644 --- a/src/mss/linux/xshmgetimage.py +++ b/src/mss/linux/xshmgetimage.py @@ -28,7 +28,7 @@ from mss.linux.xcbhelpers import LIB, XProtoError if TYPE_CHECKING: - from mss.models import Monitor + from mss.models import CaptureRegion __all__ = () @@ -282,8 +282,8 @@ def _setup_shm(self) -> ShmStatus: return ShmStatus.UNKNOWN - def _grab_xshmgetimage(self, monitor: Monitor) -> memoryview: - """Capture a monitor directly into a shared-memory slot.""" + def _grab_xshmgetimage(self, region: CaptureRegion) -> memoryview: + """Capture a region directly into a shared-memory slot.""" if self.conn is None: msg = "Cannot take screenshot while the connection is closed" raise ScreenShotError(msg) @@ -291,7 +291,7 @@ def _grab_xshmgetimage(self, monitor: Monitor) -> memoryview: # Presently, we request a buffer at least as big as our capture area. Another option would be to request a # buffer at the root size: this uses more memory, but makes it more likely that the buffers can be reused after # window resizes. This only matters if the initial buffers are in use still, and we have to create a new one. - required_size = monitor["width"] * monitor["height"] * 4 + required_size = region["width"] * region["height"] * 4 slot = self._acquire_shm_slot(required_size) assert slot.buf is not None # noqa: S101 @@ -299,10 +299,10 @@ def _grab_xshmgetimage(self, monitor: Monitor) -> memoryview: img_reply = xcb.shm_get_image( self.conn, self.drawable, - monitor["left"], - monitor["top"], - monitor["width"], - monitor["height"], + region["left"], + region["top"], + region["width"], + region["height"], ALL_PLANES, xcb.ImageFormat.ZPixmap, slot.shmseg, @@ -325,14 +325,14 @@ def _grab_xshmgetimage(self, monitor: Monitor) -> memoryview: self._release_shm_slot(slot) raise - def grab(self, monitor: Monitor) -> memoryview | bytearray: - """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" + def grab(self, region: CaptureRegion) -> memoryview | bytearray: + """Retrieve all pixels from a capture region. Pixels have to be RGBX.""" if self.shm_status == ShmStatus.UNAVAILABLE: - return super()._grab_xgetimage(monitor) + return super()._grab_xgetimage(region) # The usual path is just the next few lines. try: - rv: memoryview | bytearray = self._grab_xshmgetimage(monitor) + rv: memoryview | bytearray = self._grab_xshmgetimage(region) if self.shm_status != ShmStatus.AVAILABLE: self.shm_status = ShmStatus.AVAILABLE self.performance_status.append("MIT-SHM is working correctly.") @@ -347,7 +347,7 @@ def grab(self, monitor: Monitor) -> memoryview | bytearray: # 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_xgetimage(monitor) + rv = super()._grab_xgetimage(region) 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/models.py b/src/mss/models.py index 90f39fdc..eb244b72 100644 --- a/src/mss/models.py +++ b/src/mss/models.py @@ -2,11 +2,72 @@ # Source: https://github.com/BoboTiG/python-mss. from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, overload + + +class CaptureRegion(TypedDict): + """Rectangular screen region to capture.""" + + left: int + top: int + width: int + height: int + + +@dataclass(frozen=True, slots=True) +class Monitor: + """Monitor geometry and optional platform metadata. + + The optional metadata attributes are: + + - ``is_primary``: whether this is the primary monitor; ``None`` means + the platform could not determine it. + - ``name``: the human-readable device name; ``None`` means it is + unavailable. + - ``unique_id``: the platform-specific stable identifier; ``None`` + means it is unavailable. + - ``output``: the Linux output name compatible with xrandr; ``None`` + means it is unavailable or does not apply to the platform. + """ + + left: int + top: int + width: int + height: int + is_primary: bool | None = None + name: str | None = None + unique_id: str | None = None + output: str | None = None + + def as_capture_region(self) -> CaptureRegion: + """Return this monitor's geometry as a capture region.""" + return { + "left": self.left, + "top": self.top, + "width": self.width, + "height": self.height, + } + + @overload + def __getitem__(self, key: Literal["left", "top", "width", "height"], /) -> int: ... + + @overload + def __getitem__(self, key: Literal["is_primary"], /) -> bool | None: ... + + @overload + def __getitem__(self, key: Literal["name", "unique_id", "output"], /) -> str | None: ... + + @overload + def __getitem__(self, key: str, /) -> int | bool | str | None: ... + + def __getitem__(self, key: str, /) -> int | bool | str | None: + """Provide temporary compatibility with string-key access.""" + if key not in {"left", "top", "width", "height", "is_primary", "name", "unique_id", "output"}: + raise KeyError(key) + return getattr(self, key) + -# TODO @BoboTiG: https://github.com/BoboTiG/python-mss/issues/470 -# Change this to a proper Monitor class in next major release. -Monitor = dict[str, Any] Monitors = list[Monitor] Pixel = tuple[int, int, int] diff --git a/src/mss/screenshot.py b/src/mss/screenshot.py index 723cd7a0..09aaaa2b 100644 --- a/src/mss/screenshot.py +++ b/src/mss/screenshot.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast from mss.exception import ScreenShotError -from mss.models import Monitor, Pixel, Pixels, Pos, Size +from mss.models import Pixel, Pixels, Pos, Size if TYPE_CHECKING: from collections.abc import Iterator @@ -20,6 +20,8 @@ import torch from typing_extensions import Buffer + from mss.models import CaptureRegion + # Type checkers can see these, but they don't get into the Sphinx docs. I'm not sure if we should do this differently. Channels = Literal["BGRA", "BGR", "RGB", "RGBA"] Layout = Literal["HWC", "CHW"] @@ -35,15 +37,18 @@ class ScreenShot: __slots__ = {"__bgra", "__pixels", "__rgb", "_raw", "pos", "size"} - def __init__(self, data: Buffer, monitor: Monitor, /, *, size: Size | None = None) -> None: + def __init__(self, data: Buffer, region: CaptureRegion, /, *, size: Size | None = None) -> None: self.__pixels: Pixels | None = None self.__rgb: memoryview | None = None #: NamedTuple of the screenshot coordinates. - self.pos: Pos = Pos(monitor["left"], monitor["top"]) + self.pos: Pos = Pos(region["left"], region["top"]) #: NamedTuple of the screenshot size. - self.size: Size = Size(monitor["width"], monitor["height"]) if size is None else size + if size is not None: + self.size: Size = size + else: + self.size = Size(region["width"], region["height"]) # Buffer of the raw BGRA pixels, retrieved by the platform-specific implementations. self._raw: memoryview[int] = memoryview(data) @@ -84,8 +89,8 @@ def __array_interface__(self) -> dict[str, Any]: @classmethod def from_size(cls: type[ScreenShot], data: Buffer, width: int, height: int, /) -> ScreenShot: """Instantiate a new class given only screenshot's data and size.""" - monitor = {"left": 0, "top": 0, "width": width, "height": height} - return cls(data, monitor) + region: CaptureRegion = {"left": 0, "top": 0, "width": width, "height": height} + return cls(data, region) @property def bgra(self) -> memoryview[int]: diff --git a/src/mss/windows/gdi.py b/src/mss/windows/gdi.py index 7e2edb86..51cad8f1 100644 --- a/src/mss/windows/gdi.py +++ b/src/mss/windows/gdi.py @@ -32,12 +32,12 @@ from mss.base import MSSImplementation from mss.exception import ScreenShotError +from mss.models import Monitor if TYPE_CHECKING: from collections.abc import Callable - from typing import Any - from mss.models import CFunctionsErrChecked, Monitor, Monitors + from mss.models import CaptureRegion, CFunctionsErrChecked, Monitors __all__ = () @@ -276,12 +276,12 @@ def monitors(self) -> Monitors: # All monitors monitors.append( - { - "left": int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN - "top": int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN - "width": int_(get_system_metrics(78)), # SM_CXVIRTUALSCREEN - "height": int_(get_system_metrics(79)), # SM_CYVIRTUALSCREEN - }, + Monitor( + left=int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN + top=int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN + width=int_(get_system_metrics(78)), # SM_CXVIRTUALSCREEN + height=int_(get_system_metrics(79)), # SM_CYVIRTUALSCREEN + ), ) # Each monitor @@ -323,26 +323,25 @@ def callback(hmonitor: HMONITOR, _data: HDC, rect: LPRECT, _dc: LPARAM) -> bool: ): unique_id = ctypes.wstring_at(ctypes.addressof(display_device.DeviceID)) - mon_dict: dict[str, Any] = { - "left": left, - "top": top, - "width": int_(rct.right) - left, - "height": int_(rct.bottom) - top, - "is_primary": is_primary, - } - if device_string is not None: - mon_dict["name"] = device_string - if unique_id is not None: - mon_dict["unique_id"] = unique_id - monitors.append(mon_dict) + monitors.append( + Monitor( + left=left, + top=top, + width=int_(rct.right) - left, + height=int_(rct.bottom) - top, + is_primary=is_primary, + name=device_string, + unique_id=unique_id, + ), + ) return True user32.EnumDisplayMonitors(0, None, callback, 0) return monitors - def grab(self, monitor: Monitor, /) -> bytearray: - """Retrieve all pixels from a monitor using CreateDIBSection. + def grab(self, region: CaptureRegion, /) -> bytearray: + """Retrieve all pixels from a capture region using CreateDIBSection. Device contexts (srcdc / memdc) are acquired and released within each call. This avoids holding GDI resources across threads and allows @@ -360,7 +359,7 @@ def grab(self, monitor: Monitor, /) -> bytearray: try: memdc = gdi.CreateCompatibleDC(srcdc) try: - width, height = monitor["width"], monitor["height"] + width, height = region["width"], region["height"] if self._region_width_height != (width, height): self._region_width_height = (width, height) @@ -392,7 +391,17 @@ def grab(self, monitor: Monitor, /) -> bytearray: gdi.SelectObject(memdc, self._dib) # BitBlt copies screen content directly into the DIB's memory - gdi.BitBlt(memdc, 0, 0, width, height, srcdc, monitor["left"], monitor["top"], SRCCOPY | CAPTUREBLT) + gdi.BitBlt( + memdc, + 0, + 0, + width, + height, + srcdc, + region["left"], + region["top"], + SRCCOPY | CAPTUREBLT, + ) # Flush GDI operations to ensure DIB memory is fully updated before reading. gdi.GdiFlush() diff --git a/src/tests/bench_grab_windows.py b/src/tests/bench_grab_windows.py index c421e62a..5e3f2278 100644 --- a/src/tests/bench_grab_windows.py +++ b/src/tests/bench_grab_windows.py @@ -26,7 +26,7 @@ def benchmark_grab() -> tuple[float, float]: """ with mss.MSS() as sct: monitor = sct.monitors[1] # Primary monitor - width, height = monitor["width"], monitor["height"] + width, height = monitor.width, monitor.height print(f"Platform: {sys.platform}") print(f"Region: {width}x{height}") @@ -113,8 +113,8 @@ def benchmark_raw_bitblt() -> None: with mss.MSS() as sct: monitor = sct.monitors[1] - width, height = monitor["width"], monitor["height"] - left, top = monitor["left"], monitor["top"] + width, height = monitor.width, monitor.height + left, top = monitor.left, monitor.top # Acquire DCs directly for raw benchmarking (the impl no longer # holds them as instance state — they are per-grab now). @@ -151,7 +151,7 @@ def analyze_frame_timing() -> None: with mss.MSS() as sct: monitor = sct.monitors[1] - width, height = monitor["width"], monitor["height"] + width, height = monitor.width, monitor.height print("Frame timing analysis") print(f"Region: {width}x{height}") diff --git a/src/tests/test_cls_image.py b/src/tests/test_cls_image.py index 16564575..75056938 100644 --- a/src/tests/test_cls_image.py +++ b/src/tests/test_cls_image.py @@ -6,13 +6,13 @@ from typing import Any from mss import MSS -from mss.models import Monitor +from mss.models import CaptureRegion class SimpleScreenShot: - def __init__(self, data: bytearray, monitor: Monitor, **_: Any) -> None: + def __init__(self, data: bytearray, region: CaptureRegion, **_: Any) -> None: self.raw = bytes(data) - self.monitor = monitor + self.region = region def test_custom_cls_image(mss_impl: Callable[..., MSS]) -> None: @@ -22,4 +22,4 @@ def test_custom_cls_image(mss_impl: Callable[..., MSS]) -> None: image = sct.grab(mon1) assert isinstance(image, SimpleScreenShot) assert isinstance(image.raw, bytes) - assert isinstance(image.monitor, dict) + assert image.region == mon1.as_capture_region() diff --git a/src/tests/test_find_monitors.py b/src/tests/test_find_monitors.py index adf7a55b..f765e1b7 100644 --- a/src/tests/test_find_monitors.py +++ b/src/tests/test_find_monitors.py @@ -5,33 +5,35 @@ from collections.abc import Callable from mss import MSS +from mss.models import Monitor def test_get_monitors(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: assert sct.monitors + assert all(isinstance(monitor, Monitor) for monitor in sct.monitors) -def test_keys_aio(mss_impl: Callable[..., MSS]) -> None: +def test_geometry_aio(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: all_monitors = sct.monitors[0] - assert "top" in all_monitors - assert "left" in all_monitors - assert "height" in all_monitors - assert "width" in all_monitors + assert isinstance(all_monitors.top, int) + assert isinstance(all_monitors.left, int) + assert isinstance(all_monitors.height, int) + assert isinstance(all_monitors.width, int) -def test_keys_monitor_1(mss_impl: Callable[..., MSS]) -> None: +def test_geometry_monitor_1(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: mon1 = sct.monitors[1] - assert "top" in mon1 - assert "left" in mon1 - assert "height" in mon1 - assert "width" in mon1 + assert isinstance(mon1.top, int) + assert isinstance(mon1.left, int) + assert isinstance(mon1.height, int) + assert isinstance(mon1.width, int) def test_dimensions(mss_impl: Callable[..., MSS]) -> None: with mss_impl() as sct: mon = sct.monitors[1] - assert mon["width"] > 0 - assert mon["height"] > 0 + assert mon.width > 0 + assert mon.height > 0 diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py index 66868f01..7022ec32 100644 --- a/src/tests/test_implementation.py +++ b/src/tests/test_implementation.py @@ -27,7 +27,7 @@ from collections.abc import Callable from typing import Any - from mss.models import Monitor, Monitors, Size + from mss.models import CaptureRegion, Monitors, Size try: from datetime import UTC @@ -45,7 +45,7 @@ class MSS0(MSSImplementation): class MSS1(MSSImplementation): """Only `grab()` implemented.""" - def grab(self, monitor: Monitor) -> None: # type: ignore[override] + def grab(self, region: CaptureRegion) -> None: # type: ignore[override] pass @@ -66,7 +66,7 @@ def __init__(self, close_error: Exception) -> None: def cursor(self) -> None: pass - def grab(self, _: Monitor) -> bytearray | tuple[bytearray, Size]: + def grab(self, _: CaptureRegion) -> bytearray | tuple[bytearray, Size]: return bytearray() def monitors(self) -> Monitors: @@ -119,7 +119,7 @@ def test_bad_monitor(mss_impl: Callable[..., MSS]) -> 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} + expected_box: CaptureRegion = {"top": 0, "left": 0, "width": 10, "height": 10} with mss_impl() as sct: img = sct.grab(box) ref = ScreenShot(bytearray(b"BGRA" * 100), expected_box) @@ -206,7 +206,15 @@ def test_custom_output_pattern(self, with_cursor: bool, capsys: pytest.CaptureFi zip(sct.monitors[1:], captured.out.splitlines(), strict=False), 1, ): - filename = Path(fmt.format(mon=mon, **monitor)) + filename = Path( + fmt.format( + mon=mon, + top=monitor.top, + left=monitor.left, + width=monitor.width, + height=monitor.height, + ), + ) assert line.endswith(filename.name) assert filename.is_file() filename.unlink() @@ -370,8 +378,8 @@ def test_grab_with_invalid_tuple(mss_impl: Callable[..., MSS]) -> 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 - top = monitor["top"] + monitor["height"] * 5 // 100 # 5% from the top + left = monitor.left + monitor.width * 5 // 100 # 5% from the left + top = monitor.top + monitor.height * 5 // 100 # 5% from the top right = left + 500 # 500px lower = top + 500 # 500px width = right - left diff --git a/src/tests/test_macos.py b/src/tests/test_macos.py index e343d2d5..02d88494 100644 --- a/src/tests/test_macos.py +++ b/src/tests/test_macos.py @@ -60,8 +60,8 @@ def test_implementation(monkeypatch: pytest.MonkeyPatch) -> None: 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"] + assert original.width == modified.height + assert original.height == modified.width monkeypatch.undo() # Test bad data retrieval diff --git a/src/tests/test_models.py b/src/tests/test_models.py new file mode 100644 index 00000000..63f14c66 --- /dev/null +++ b/src/tests/test_models.py @@ -0,0 +1,67 @@ +"""Tests for public data models.""" + +from collections.abc import Mapping +from dataclasses import FrozenInstanceError + +import pytest + +from mss.models import Monitor + + +def test_monitor() -> None: + monitor = Monitor( + left=1, + top=2, + width=3, + height=4, + is_primary=True, + name="Display", + unique_id="display-id", + output="DP-1", + ) + + assert (monitor.left, monitor.top, monitor.width, monitor.height) == (1, 2, 3, 4) + assert monitor.is_primary is True + assert monitor.name == "Display" + assert monitor.unique_id == "display-id" + assert monitor.output == "DP-1" + assert monitor.as_capture_region() == {"left": 1, "top": 2, "width": 3, "height": 4} + assert not isinstance(monitor, Mapping) + assert not hasattr(monitor, "__dict__") + + with pytest.raises(FrozenInstanceError): + monitor.width = 5 # type: ignore[misc] + + +def test_monitor_string_key_access() -> None: + monitor = Monitor( + left=1, + top=2, + width=3, + height=4, + is_primary=True, + name="Display", + unique_id="display-id", + output="DP-1", + ) + + assert monitor["left"] == monitor.left + assert monitor["top"] == monitor.top + assert monitor["width"] == monitor.width + assert monitor["height"] == monitor.height + assert monitor["is_primary"] == monitor.is_primary + assert monitor["name"] == monitor.name + assert monitor["unique_id"] == monitor.unique_id + assert monitor["output"] == monitor.output + + with pytest.raises(KeyError): + monitor["unknown"] + + +def test_monitor_optional_metadata_defaults_to_none() -> None: + monitor = Monitor(left=1, top=2, width=3, height=4) + + assert monitor.is_primary is None + assert monitor.name is None + assert monitor.unique_id is None + assert monitor.output is None diff --git a/src/tests/test_primary_monitor.py b/src/tests/test_primary_monitor.py index 160abc82..11759c4a 100644 --- a/src/tests/test_primary_monitor.py +++ b/src/tests/test_primary_monitor.py @@ -8,6 +8,7 @@ import pytest from mss import MSS +from mss.models import Monitor def test_primary_monitor(mss_impl: Callable[..., MSS]) -> None: @@ -16,19 +17,14 @@ def test_primary_monitor(mss_impl: Callable[..., MSS]) -> None: primary = sct.primary_monitor monitors = sct.monitors - # Should return a valid monitor dict - assert isinstance(primary, dict) - assert "left" in primary - assert "top" in primary - assert "width" in primary - assert "height" in primary + assert isinstance(primary, Monitor) # Should be in the monitors list (excluding index 0 which is "all monitors") assert primary in monitors[1:] # Should either be marked as primary or be the first monitor as fallback - if primary.get("is_primary", False): - assert primary["is_primary"] is True + if primary.is_primary: + assert primary.is_primary is True else: assert primary == monitors[1] @@ -40,7 +36,7 @@ def test_primary_monitor_coordinates_windows() -> None: with mss.MSS() as sct: primary = sct.primary_monitor - if primary.get("is_primary", False): + if primary.is_primary: # On Windows, the primary monitor is at (0, 0) - assert primary["left"] == 0 - assert primary["top"] == 0 + assert primary.left == 0 + assert primary.top == 0 diff --git a/src/tests/test_save.py b/src/tests/test_save.py index 275c4d11..b9b611e8 100644 --- a/src/tests/test_save.py +++ b/src/tests/test_save.py @@ -60,10 +60,36 @@ 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) - assert filename == fmt.format(**sct.monitors[1]) + monitor = sct.monitors[1] + assert filename == fmt.format( + top=monitor.top, + left=monitor.left, + width=monitor.width, + height=monitor.height, + ) assert Path(filename).is_file() +def test_output_format_optional(mss_impl: Callable[..., MSS]) -> None: + class FormattingCompleteError(Exception): + pass + + filename = "" + + def capture_filename(value: str) -> None: + nonlocal filename + filename = value + raise FormattingCompleteError + + fmt = "sct-{is_primary}-{unique_id}.png" + with mss_impl() as sct: + monitor = sct.monitors[1] + with pytest.raises(FormattingCompleteError): + next(sct.save(mon=1, output=fmt, callback=capture_filename)) + + assert filename == fmt.format(is_primary=monitor.is_primary, unique_id=monitor.unique_id) + + def test_output_format_date_simple(mss_impl: Callable[..., MSS]) -> None: fmt = "sct_{mon}-{date}.png" with mss_impl() as sct: diff --git a/src/tests/test_setup.py b/src/tests/test_setup.py index f77f6275..432bbcb6 100644 --- a/src/tests/test_setup.py +++ b/src/tests/test_setup.py @@ -106,6 +106,7 @@ def test_sdist() -> None: f"mss-{__version__}/src/tests/test_issue_220.py", f"mss-{__version__}/src/tests/test_leaks.py", f"mss-{__version__}/src/tests/test_macos.py", + f"mss-{__version__}/src/tests/test_models.py", f"mss-{__version__}/src/tests/test_primary_monitor.py", f"mss-{__version__}/src/tests/test_save.py", f"mss-{__version__}/src/tests/test_setup.py", diff --git a/src/tests/test_windows.py b/src/tests/test_windows.py index 7f6061d6..f5548b21 100644 --- a/src/tests/test_windows.py +++ b/src/tests/test_windows.py @@ -163,7 +163,7 @@ def test_monitors_work_when_getwindowdc_fails() -> None: try: monitors = sct.monitors assert len(monitors) >= 1 - assert "width" in monitors[0] + assert monitors[0].width > 0 with pytest.raises(ScreenShotError): sct.grab(monitors[1])