Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
See Git commit messages for full history.

## 10.2.0.dev0 (2026-xx-xx)
- Add `Monitor` class to replace `dict[str, int]` with `is_primary` and `name` attributes (#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)
- Windows: add monitor device name extraction using `EnumDisplayDevicesW` API (#153)
- Windows: switch from `GetDIBits` to more memory efficient `CreateDIBSection` for `MSS.grab` implementation (#449)
- Windows: fix gdi32.GetDIBits() failed after a couple of minutes of recording (#268)
- Linux: check the server for Xrandr support version (#417)
- Linux: improve typing and error messages for X libraries (#418)
- Linux: introduce an XCB-powered backend stack with a factory in ``mss.linux`` while keeping the Xlib code as a fallback (#425)
- Linux: add the XShmGetImage backend with automatic XGetImage fallback and explicit status reporting (#431)
- Windows: improve error checking and messages for Win32 API calls (#448)
- Mac: fix memory leak (#450, #453)
- improve multithreading: allow multiple threads to use the same MSS object, allow multiple MSS objects to concurrently take screenshots, and document multithreading guarantees (#446, #452)
- Add full demos for different ways to use MSS (#444, #456, #465)
Expand Down
45 changes: 37 additions & 8 deletions src/mss/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def close(self) -> None:
self._close_impl()
self._closed = True

def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot:
def grab(self, monitor: Monitor | dict[str, int] | tuple[int, int, int, int], /) -> ScreenShot:
"""Retrieve screen pixels for a given monitor.

Note: ``monitor`` can be a tuple like the one
Expand All @@ -177,14 +177,23 @@ def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot:
See :meth:`monitors <monitors>` for object details.
:returns: Screenshot of the requested region.
"""
# Convert PIL bbox style
from mss.models import Monitor as MonitorCls # noqa: PLC0415

# Convert PIL bbox style or dict to Monitor
if isinstance(monitor, tuple):
monitor = {
"left": monitor[0],
"top": monitor[1],
"width": monitor[2] - monitor[0],
"height": monitor[3] - monitor[1],
}
monitor = MonitorCls(
monitor[0],
monitor[1],
monitor[2] - monitor[0],
monitor[3] - monitor[1],
)
elif isinstance(monitor, dict):
monitor = MonitorCls(
monitor["left"],
monitor["top"],
monitor["width"],
monitor["height"],
)

if monitor["width"] <= 0 or monitor["height"] <= 0:
msg = f"Region has zero or negative size: {monitor!r}"
Expand Down Expand Up @@ -220,6 +229,26 @@ def monitors(self) -> Monitors:
self._monitors_impl()
return self._monitors

@property
def primary_monitor(self) -> Monitor | None:
"""Get the primary monitor.

Returns the monitor marked as primary. If no monitor is marked as primary
(or the platform doesn't support primary monitor detection), returns the
first monitor (at index 1). Returns None if no monitors are available.

.. versionadded:: 10.2.0
"""
monitors = self.monitors
if len(monitors) <= 1: # Only the "all monitors" entry or empty
return None

for monitor in monitors[1:]: # Skip the "all monitors" entry at index 0
if monitor.is_primary:
return monitor
# Fallback to the first monitor if no primary is found
return monitors[1]

def save(
self,
/,
Expand Down
29 changes: 15 additions & 14 deletions src/mss/darwin.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@

from mss.base import MSSBase
from mss.exception import ScreenShotError
from mss.models import Monitor
from mss.screenshot import ScreenShot, Size

if TYPE_CHECKING: # pragma: nocover
from mss.models import CFunctions, Monitor
from mss.models import CFunctions

__all__ = ("IMAGE_OPTIONS", "MSS")

Expand Down Expand Up @@ -158,7 +159,7 @@ def _monitors_impl(self) -> None:
# 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({})
self._monitors.append(Monitor(0, 0, 0, 0)) # Placeholder, updated later

# Each monitor
display_count = c_uint32(0)
Expand All @@ -177,24 +178,24 @@ def _monitors_impl(self) -> None:
width, height = height, width

self._monitors.append(
{
"left": int_(rect.origin.x),
"top": int_(rect.origin.y),
"width": int_(width),
"height": int_(height),
},
Monitor(
int_(rect.origin.x),
int_(rect.origin.y),
int_(width),
int_(height),
),
)

# Update AiO monitor's values
all_monitors = core.CGRectUnion(all_monitors, rect)

# Set the AiO monitor's values
self._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),
}
self._monitors[0] = Monitor(
int_(all_monitors.origin.x),
int_(all_monitors.origin.y),
int_(all_monitors.size.width),
int_(all_monitors.size.height),
)

def _grab_impl(self, monitor: Monitor, /) -> ScreenShot:
"""Retrieve all pixels from a monitor. Pixels have to be RGB."""
Expand Down
30 changes: 14 additions & 16 deletions src/mss/linux/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

from mss.base import MSSBase
from mss.exception import ScreenShotError
from mss.models import Monitor

from . import xcb
from .xcb import LIB

if TYPE_CHECKING:
from mss.models import Monitor
from mss.screenshot import ScreenShot

SUPPORTED_DEPTHS = {24, 32}
Expand Down Expand Up @@ -144,12 +144,12 @@ def _monitors_impl(self) -> None:
# monitors.
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,
}
Monitor(
root_geom.x,
root_geom.y,
root_geom.width,
root_geom.height,
)
)

# After that, we have one for each monitor on that X11 screen. For decades, that's been handled by
Expand Down Expand Up @@ -186,9 +186,7 @@ def _monitors_impl(self) -> None:
crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, screen_resources.config_timestamp)
if crtc_info.num_outputs == 0:
continue
self._monitors.append(
{"left": crtc_info.x, "top": crtc_info.y, "width": crtc_info.width, "height": crtc_info.height}
)
self._monitors.append(Monitor(crtc_info.x, crtc_info.y, crtc_info.width, crtc_info.height))

# Extra credit would be to enumerate the virtual desktops; see
# https://specifications.freedesktop.org/wm/latest/ar01s03.html. But I don't know how widely-used that
Expand Down Expand Up @@ -232,12 +230,12 @@ def _cursor_impl(self) -> ScreenShot:
raise ScreenShotError(msg)

cursor_img = xcb.xfixes_get_cursor_image(self.conn)
region = {
"left": cursor_img.x - cursor_img.xhot,
"top": cursor_img.y - cursor_img.yhot,
"width": cursor_img.width,
"height": cursor_img.height,
}
region = Monitor(
cursor_img.x - cursor_img.xhot,
cursor_img.y - cursor_img.yhot,
cursor_img.width,
cursor_img.height,
)

data_arr = xcb.xfixes_get_cursor_image_cursor_image(cursor_img)
data = bytearray(data_arr)
Expand Down
35 changes: 18 additions & 17 deletions src/mss/linux/xlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@

from mss.base import MSSBase
from mss.exception import ScreenShotError
from mss.models import Monitor

if TYPE_CHECKING: # pragma: nocover
from mss.models import CFunctions, Monitor
from mss.models import CFunctions
from mss.screenshot import ScreenShot

__all__ = ("MSS",)
Expand Down Expand Up @@ -542,7 +543,7 @@ def _monitors_impl(self) -> None:
gwa = XWindowAttributes()
self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa))
self._monitors.append(
{"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)},
Monitor(int_(gwa.x), int_(gwa.y), int_(gwa.width), int_(gwa.height)),
)

# Each monitor
Expand All @@ -565,12 +566,12 @@ def _monitors_impl(self) -> None:
continue

self._monitors.append(
{
"left": int_(crtc.x),
"top": int_(crtc.y),
"width": int_(crtc.width),
"height": int_(crtc.height),
},
Monitor(
int_(crtc.x),
int_(crtc.y),
int_(crtc.width),
int_(crtc.height),
),
)
xrandr.XRRFreeCrtcInfo(crtc)
xrandr.XRRFreeScreenResources(mon)
Expand Down Expand Up @@ -618,17 +619,17 @@ def _cursor_impl(self) -> ScreenShot:
raise ScreenShotError(msg)

cursor_img: XFixesCursorImage = ximage.contents
region = {
"left": cursor_img.x - cursor_img.xhot,
"top": cursor_img.y - cursor_img.yhot,
"width": cursor_img.width,
"height": cursor_img.height,
}

raw_data = cast(cursor_img.pixels, POINTER(c_ulong * region["height"] * region["width"]))
region = Monitor(
cursor_img.x - cursor_img.xhot,
cursor_img.y - cursor_img.yhot,
cursor_img.width,
cursor_img.height,
)

raw_data = cast(cursor_img.pixels, POINTER(c_ulong * region.height * region.width))
raw = bytearray(raw_data.contents)

data = bytearray(region["height"] * region["width"] * 4)
data = bytearray(region.height * region.width * 4)
data[3::4] = raw[3::8]
data[2::4] = raw[2::8]
data[1::4] = raw[1::8]
Expand Down
65 changes: 62 additions & 3 deletions src/mss/models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,73 @@
# This is part of the MSS Python's module.
# Source: https://github.com/BoboTiG/python-mss.
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Callable, NamedTuple

Monitor = dict[str, int]
Monitors = list[Monitor]

Pixel = tuple[int, int, int]
Pixels = list[tuple[Pixel, ...]]


class Monitor:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So far, I've been careful to not change any existing aspects of the public API surface. I haven't talked with @BoboTiG about that, though, so he may have different priorities.

This is an example of something that would be a change to that surface. The usage would still be API-compatible, but the signature wouldn't: something using my_monitor: dict = sct.monitors[1] would stop passing type-checking.

The alternative would be to have Monitor inherit from dict, and provide @property attributes that access its entries. That would provide type and API compatibility, but we could mark dict access as deprecated, and eliminate it in the next major rev, simplifying Monitor at that time.

I know I'm the one that suggested using __getattr__ in the first place, so my apologies about the course change.

As another example, I've got one change in the works that affects a lot of ScreenShot, and I'm looking at the likely changes: I'm seeing which changes can be made in an API- and type-compatible way, and which can't. Along the same lines, I'm thinking about what parts of the exposed API we want to say are "public", in the sense of semantic versioning, and which ones we want to stop exposing with public names (such as mss.windows.MSS.user32).

What do you think, @halldorfannar and @BoboTiG?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to not touch the current MSS-specific public API (primary functions and types). The example about mss.windows.MSS.user32 is not considered MSS-specific and can be made private when we want. But Monitor should be kept backward compatible.

The code being used for years still needs to work without changes after we ship the next release. And for the next major release, I am also kind of against breaking the API since this is not really necessary.

How does it sound to both of you?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to make sure we’re all aligned on terminology and expectations, since this is really about policy going forward.

First off: I completely agree with the priority of protecting existing users. MSS has earned a lot of trust, and preserving working code across releases is hugely important. Nothing here is meant to undermine that.

What I’m trying to clarify is how we define “public API” and “breaking change,” especially in Python, where the line can be fuzzy.

I’m assuming we’re roughly following Semantic Versioning principles. SemVer only really works if we agree on what the public API is. In Python, the usual convention is “no leading underscore == public,” but MSS historically hasn’t been strict about that (such as the user32 example). That’s fine — but it means we need to be explicit about what we consider stable and versioned.

One thing I’ve personally been treating as part of the public API is type declarations, not just runtime behavior. That’s increasingly important now that many users run MyPy or Pyright in CI. A type signature is effectively a promise, even if Python itself won’t enforce it at runtime.

That leads to an important distinction:

  • Runtime compatibility: what actually happens when code runs
  • Type compatibility: whether static type checkers still accept the code

They matter in different ways.

Python APIs are famously hard to change without some theoretical breakage. It’s basically impossible to guarantee that no user code could ever be affected — even adding a key to a dict could break someone doing something exotic. So instead of “never break anything,” I’ve been thinking in terms of typical usage. If something would only break highly unusual or contrived code paths, that carries much less weight than breaking common, idiomatic usage.

With that framing, here’s how I’ve been thinking about changes:

  • Runtime-breaking changes (new required parameters, removing methods, changing behavior in common paths) are the most serious. Those should only happen in a major release, after deprecation, and only if there’s a strong reason. I agree that we should avoid these without a strong reason.
  • Type-only breaking changes (changing a return type in a way that still works at runtime for typical usage) are still API breaks, so should only be at major version boundaries, but they’re lower-impact. They mostly affect users running static analysis, and usually require updating annotations rather than logic.

The Monitor discussion falls into that second category. Returning a Mapping instead of strictly a dict would still behave the same at runtime for typical access (m["left"], iteration, etc.), but it would break type checks for code that annotates it as dict. That’s why I hesitated once I realized the implication.

Now, tying this back to your response: when you say “Monitor should be kept backward compatible,” I fully agree at runtime. Where I want to be careful is whether we’re also committing to never changing exposed types, even at major versions.

The reason this matters is future performance work.

For example, today ScreenShot.bgra is declared as bytes. That implicitly commits us to copying a very large buffer every capture. On a 4K screen that’s ~32 MB, which is several milliseconds per frame — a nontrivial cost if someone is trying to hit 60 fps.

Most OS capture APIs already give us a writable buffer in CPU memory. Python can expose that safely via memoryview, avoiding the copy entirely. For typical usage, a memoryview behaves almost identically to bytes — indexing, slicing, passing to PIL / NumPy / PyTorch all work — but it’s dramatically faster.

From a runtime perspective, this change would be almost invisible to most users. From a typing perspective, however, the distinction matters more.

MSS would need to widen its type declarations (for example, from bytes, to Buffer or bytes | bytearray | memoryview) to reflect what it is returning. Any user code that has explicitly annotated bgra as bytes would start triggering type checker warnings once they upgrade. The runtime logic would still work as before, but static analysis tools would complain. That’s why I’ve been treating this class of change as a public API change, and so far only considering it at major version boundaries.

That’s the kind of change I’m trying to plan carefully around. I don’t want to sneak it in under a minor release — but I do think it’s reasonable to allow this sort of evolution at a major version boundary, because it enables MSS to better deliver on its “ultra-fast” goal.

So my proposed policy, in short, would be:

  • Keep the runtime API stable across releases, including major ones, insofar as is reasonable for typical usage. Avoid breaking existing working code unless there is a compelling reason and no practical alternative.
  • Treat type changes as public API changes, and allow them at major version boundaries when they enable meaningful improvements, even if runtime behavior for typical usage remains unchanged.
  • Be explicit about what we consider public, so users and contributors know what’s safe to rely on.

My intent here isn’t to justify breaking working code, but to make sure we have room to improve performance and structure without surprising users or their tooling.

I’m taking this opportunity to agree on the rules now, so future work doesn’t turn into a philosophical debate every time we touch a boundary.

Curious if that framing matches how you’re thinking about it, or if you see things differently.

Copy link
Owner

@BoboTiG BoboTiG Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your words are perfectly aligned with my vision, I would not have been able to better phrase it :)

I tried to follow SemVer from the beginning, hopefully enough that it made sense so far.

In my perspective, and I was too lazy at the beginning to properly make those objects private, all objects used by MSS to power MSS content (like user32 on Windows, core on macOS and so on) are not intended to be used by developers/users. I mean, they should have been private from the start, and so we can treat them as we want. It is OK to make them private once and for all.

About the MSS API already used by thousands of people, I do not close the door to improvements, and sometimes necessary breaking changes. If that deserves MSS, then lets do it!

As I am typing, I am thinking that if we change the type of Monitor, for instance, then it might be doable without, hopefully, too many problems to users: in a user code using Monitor, I guess they import the type object from MSS, so if we change it, this could be done transparently to them (best case scenario).

All in all, I am not a conservator, I know pretty well things must change if we want to move forward. Lets doing it with parcimony, and keep users in mind everytime since I would prefer to lower their burden at each release.

I am also totally aligned about preventing philosophy talks at each change (I am not a meeting person, I prefer action first :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I enjoy this conversation. It is the right discussion to have, even if it's happening as fallout from my draft MR 😄 I am also a stickler for SemVer. I have followed it on many projects to the letter, and even though it has felt constricting and painful at times, my customers have always thanked me for it.

One of the reasons I made this MR a draft was exactly the uneasy feeling I got when I saw the fallout from changing the Monitor type. Even though I was bending over backwards to ensure runtime compatibility there were still issues, as @jholveck has rightly pointed out. I also just don't like it when a supposedly simple change spreads into multiple files. It feels like a smell, something is off.

My suggestion therefore is that we keep Monitor as a dict type for now. But we add a TODO in there (or whatever mechanism this project prefers for future annnouncement) about this becoming a Class type in the next major release. That way we announce our intent.

I would also like for us to do this with other potential changes we may want to make in the future. If they require changes that break the API we need to target them for the next major release. As part of release planning we should collect those somewhere, so that we can look up the next major release and see what things we are planning to break with that release. I emphasize planning because in my experience we usually find that not all changes get implemented (some are deemed low-value while others have in the light of time and experience been deemed a bad idea 😆).

Finally, I think we should get low-hanging fruit (like this Monitor improvement) into shape so it causes no breakage and cut a release soon. We should then discuss when the next major release is planned, so we can start aligning changes with the right release (major or minor).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A TODO could be added right above the Monitor declaration. It will be a matter of looking for "TODO" strings if anyone wants to give a try.

As MSS is quite stable, there is no really version schedule. When there is a known problem with a fix, or an improvement done, then I cut a fresh version.

Since the latest available MSS version, changes to be released are huge! Cutting a new release soon would allow us to break things for the next version, which would be a major.
I would like to spread those significant improvements @jholveck and you provided so far to the community without a major release.

So yes, maybe sticking to Monitor as a dict would be a good thing until the next release.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All righty then! This is a smaller change now which I think is better for everybody involved. I have removed the "Draft" label. Please review at your leisure.

"""Represents a display monitor with its position and dimensions.

:param left: The x-coordinate of the upper-left corner.
:param top: The y-coordinate of the upper-left corner.
:param width: The width of the monitor.
:param height: The height of the monitor.
:param is_primary: Whether this is the primary monitor.
:param name: The device name of the monitor (platform-specific).
"""

__slots__ = ("height", "is_primary", "left", "name", "top", "width")

def __init__( # noqa: PLR0913
self,
left: int,
top: int,
width: int,
height: int,
*,
is_primary: bool = False,
name: str = "",
) -> None:
self.left = left
self.top = top
self.width = width
self.height = height
self.is_primary = is_primary
self.name = name

def __repr__(self) -> str:
return (
f"Monitor(left={self.left}, top={self.top}, width={self.width}, "
f"height={self.height}, is_primary={self.is_primary}, name={self.name!r})"
)

def __getitem__(self, key: str) -> int | bool:
"""Provide dict-like access for backward compatibility."""
try:
return getattr(self, key)
except AttributeError as exc:
raise KeyError(key) from exc

def __setitem__(self, key: str, value: int | bool) -> None:
"""Provide dict-like setitem for backward compatibility."""
if not hasattr(self, key):
raise KeyError(key)
setattr(self, key, value)

def keys(self) -> list[str]:
"""Provide dict-like keys() for backward compatibility."""
return list(self.__slots__)

def __contains__(self, key: str) -> bool:
"""Provide dict-like 'in' operator for backward compatibility."""
return hasattr(self, key) and key in self.__slots__


Monitors = list[Monitor]

if TYPE_CHECKING:
CFunctions = dict[str, tuple[str, list[Any], Any]]
CFunctionsErrChecked = dict[str, tuple[str, list[Any], Any, Callable | None]]
Expand Down
4 changes: 3 additions & 1 deletion src/mss/screenshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ def __array_interface__(self) -> dict[str, Any]:
@classmethod
def from_size(cls: type[ScreenShot], data: bytearray, 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}
from mss.models import Monitor # noqa: PLC0415

monitor = Monitor(0, 0, width, height)
return cls(data, monitor)

@property
Expand Down
Loading