Skip to content

Commit 416a851

Browse files
committed
Improve testing and comments
1 parent a33f6e0 commit 416a851

File tree

9 files changed

+437
-200
lines changed

9 files changed

+437
-200
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ See Git checking messages for full history.
55
## 10.1.1.dev0 (2025-xx-xx)
66
- Linux: check the server for Xrandr support version (#417)
77
- Linux: improve typing and error messages for X libraries (#418)
8+
- Linux: add a new XCB backend for better thread safety, error-checking, and future development (#425)
89
- :heart: contributors: @jholveck
910

1011
## 10.1.0 (2025-08-16)

src/mss/linux/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
from mss.exception import ScreenShotError
55

66

7-
# This factory function is named in upper-case for backwards compatibility.
8-
def MSS(backend: str = "default", **kwargs: Any) -> MSSBase: # noqa: N802
7+
def mss(backend: str = "default", **kwargs: Any) -> MSSBase:
98
backend = backend.lower()
109
if backend in {"default", "xlib"}:
1110
from . import xlib # noqa: PLC0415
@@ -17,3 +16,8 @@ def MSS(backend: str = "default", **kwargs: Any) -> MSSBase: # noqa: N802
1716
return xgetimage.MSS(**kwargs)
1817
msg = f"Backend {backend!r} not (yet?) implemented."
1918
raise ScreenShotError(msg)
19+
20+
21+
# Alias in upper-case for backward compatibility. This is a supported name in the docs.
22+
def MSS(*args, **kwargs) -> MSSBase: # type: ignore[no-untyped-def] # noqa: N802, ANN002, ANN003
23+
return mss(*args, **kwargs)

src/mss/linux/xcb.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,18 +120,23 @@
120120
}
121121

122122

123+
def initialize() -> None:
124+
LIB.initialize(callbacks=[xcbgen.initialize])
125+
126+
123127
def connect(display: str | bytes | None = None) -> tuple[Connection, int]:
124128
if isinstance(display, str):
125129
display = display.encode("utf-8")
126130

127-
LIB.initialize(callbacks=[xcbgen.initialize])
128-
131+
initialize()
129132
pref_screen_num = c_int()
130133
conn_p = LIB.xcb.xcb_connect(display, pref_screen_num)
131134

132135
# We still get a connection object even if the connection fails.
133136
conn_err = LIB.xcb.xcb_connection_has_error(conn_p)
134137
if conn_err != 0:
138+
# XCB won't free its connection structures until we disconnect, even in the event of an error.
139+
LIB.xcb.xcb_disconnect(conn_p)
135140
msg = "Cannot connect to display: "
136141
conn_errmsg = XCB_CONN_ERRMSG.get(conn_err)
137142
if conn_errmsg:

src/mss/linux/xcbhelpers.py

Lines changed: 127 additions & 155 deletions
Large diffs are not rendered by default.

src/mss/linux/xgetimage.py

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,11 @@ def __init__(self, /, **kwargs: Any) -> None: # noqa: PLR0912
3535
self.conn: xcb.Connection | None
3636
self.conn, pref_screen_num = xcb.connect(display)
3737

38-
# Let XCB pre-populate its internal cache regarding the
39-
# extensions we might use, while we finish setup.
38+
# Let XCB pre-populate its internal cache regarding the extensions we might use, while we finish setup.
4039
LIB.xcb.xcb_prefetch_extension_data(self.conn, LIB.randr_id)
4140
LIB.xcb.xcb_prefetch_extension_data(self.conn, LIB.xfixes_id)
4241

43-
# Get the connection setup information that was included when we
44-
# connected.
42+
# Get the connection setup information that was included when we connected.
4543
xcb_setup = LIB.xcb.xcb_get_setup(self.conn).contents
4644
screens = xcb.setup_roots(xcb_setup)
4745
pref_screen = screens[pref_screen_num]
@@ -128,8 +126,7 @@ def _monitors_impl(self) -> None:
128126
msg = "Cannot identify monitors while the connection is closed"
129127
raise ScreenShotError(msg)
130128

131-
# The first entry is the whole X11 screen that the root is
132-
# on. That's the one that covers all the monitors.
129+
# The first entry is the whole X11 screen that the root is on. That's the one that covers all the monitors.
133130
root_geom = xcb.get_geometry(self.conn, self.root)
134131
self._monitors.append(
135132
{
@@ -140,52 +137,47 @@ def _monitors_impl(self) -> None:
140137
}
141138
)
142139

143-
# After that, we have one for each monitor on that X11 screen.
144-
# For decades, that's been handled by Xrandr. We don't
145-
# presently try to work with Xinerama. So, we're going to
146-
# check the different outputs, according to Xrandr. If that
147-
# fails, we'll just leave the one root covering everything.
140+
# After that, we have one for each monitor on that X11 screen. For decades, that's been handled by Xrandr.
141+
# We don't presently try to work with Xinerama. So, we're going to check the different outputs, according to
142+
# Xrandr. If that fails, we'll just leave the one root covering everything.
148143

149-
# Make sure we have the Xrandr extension we need. This will
150-
# query the cache that we started populating in __init__.
144+
# Make sure we have the Xrandr extension we need. This will query the cache that we started populating in
145+
# __init__.
151146
randr_ext_data = LIB.xcb.xcb_get_extension_data(self.conn, LIB.randr_id).contents
152147
if not randr_ext_data.present:
153148
return
154149

155-
# We ask the server to give us anything up to the version we
156-
# support (i.e., what we expect the reply structs to look
157-
# like). If the server only supports 1.2, then that's what
158-
# it'll give us, and we're ok with that, but we also use a
159-
# faster path if the server implements at least 1.3.
150+
# We ask the server to give us anything up to the version we support (i.e., what we expect the reply structs
151+
# to look like). If the server only supports 1.2, then that's what it'll give us, and we're ok with that, but
152+
# we also use a faster path if the server implements at least 1.3.
160153
randr_version_data = xcb.randr_query_version(self.conn, xcb.RANDR_MAJOR_VERSION, xcb.RANDR_MINOR_VERSION)
161154
randr_version = (randr_version_data.major_version, randr_version_data.minor_version)
162155
if randr_version < (1, 2):
163156
return
164157

165158
screen_resources: xcb.RandrGetScreenResourcesReply | xcb.RandrGetScreenResourcesCurrentReply
166-
# Check to see if we have the xcb_randr_get_screen_resources_current
167-
# function in libxcb-randr, and that the server supports it.
159+
# Check to see if we have the xcb_randr_get_screen_resources_current function in libxcb-randr, and that the
160+
# server supports it.
168161
if hasattr(LIB.randr, "xcb_randr_get_screen_resources_current") and randr_version >= (1, 3):
169162
screen_resources = xcb.randr_get_screen_resources_current(self.conn, self.drawable.value)
170163
crtcs = xcb.randr_get_screen_resources_current_crtcs(screen_resources)
171164
else:
172-
# Either the client or the server doesn't support the _current
173-
# form. That's ok; we'll use the old function, which forces
174-
# a new query to the physical monitors.
165+
# Either the client or the server doesn't support the _current form. That's ok; we'll use the old
166+
# function, which forces a new query to the physical monitors.
175167
screen_resources = xcb.randr_get_screen_resources(self.conn, self.drawable)
176168
crtcs = xcb.randr_get_screen_resources_crtcs(screen_resources)
177169

178170
for crtc in crtcs:
179-
crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, screen_resources.timestamp)
171+
crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, screen_resources.config_timestamp)
180172
if crtc_info.num_outputs == 0:
181173
continue
182174
self._monitors.append(
183175
{"left": crtc_info.x, "top": crtc_info.y, "width": crtc_info.width, "height": crtc_info.height}
184176
)
185177

186178
# Extra credit would be to enumerate the virtual desktops; see
187-
# https://specifications.freedesktop.org/wm/latest/ar01s03.html
188-
# But I don't know how widely-used that style is.
179+
# https://specifications.freedesktop.org/wm/latest/ar01s03.html. But I don't know how widely-used that style
180+
# is.
189181

190182
def _grab_impl(self, monitor: Monitor, /) -> ScreenShot:
191183
"""Retrieve all pixels from a monitor. Pixels have to be RGBX."""
@@ -205,11 +197,9 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot:
205197
ALL_PLANES,
206198
)
207199

208-
# Now, save the image. This is a reference into the img_reply
209-
# structure.
200+
# Now, save the image. This is a reference into the img_reply structure.
210201
img_data_arr = xcb.get_image_data(img_reply)
211-
# Copy this into a new bytearray, so that it will persist after
212-
# we clear the image structure.
202+
# Copy this into a new bytearray, so that it will persist after we clear the image structure.
213203
img_data = bytearray(img_data_arr)
214204

215205
if img_reply.depth != self.drawable_depth or img_reply.visual.value != self.drawable_visual_id:
@@ -233,9 +223,8 @@ def _cursor_impl_check_xfixes(self) -> bool:
233223
return False
234224

235225
reply = xcb.xfixes_query_version(self.conn, xcb.XFIXES_MAJOR_VERSION, xcb.XFIXES_MINOR_VERSION)
236-
# We can work with 2.0 and later, but not sure about the
237-
# actual minimum version we can use. That's ok; everything
238-
# these days is much more modern.
226+
# We can work with 2.0 and later, but not sure about the actual minimum version we can use. That's ok;
227+
# everything these days is much more modern.
239228
return (reply.major_version, reply.minor_version) >= (2, 0)
240229

241230
def _cursor_impl(self) -> ScreenShot:
@@ -261,9 +250,7 @@ def _cursor_impl(self) -> ScreenShot:
261250

262251
data_arr = xcb.xfixes_get_cursor_image_cursor_image(cursor_img)
263252
data = bytearray(data_arr)
264-
# We don't need to do the same array slice-and-dice work as
265-
# the Xlib-based implementation: Xlib has an unfortunate
266-
# historical accident that makes it have to return the cursor
267-
# image in a different format.
253+
# We don't need to do the same array slice-and-dice work as the Xlib-based implementation: Xlib has an
254+
# unfortunate historical accident that makes it have to return the cursor image in a different format.
268255

269256
return self.cls_image(data, region)

src/tests/test_gnu_linux.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,19 @@ def test_xrandr_extension_exists_but_is_not_enabled(display: str) -> None:
152152

153153

154154
def test_unsupported_depth(backend: str) -> None:
155+
# 8-bit is normally PseudoColor. If the order of testing the display support changes, this might raise a
156+
# different message; just change the match= accordingly.
155157
with (
156158
pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=8) as vdisplay,
157-
pytest.raises(ScreenShotError),
159+
pytest.raises(ScreenShotError, match=r"\b8\b"),
160+
mss.mss(display=vdisplay.new_display_var, backend=backend) as sct,
161+
):
162+
sct.grab(sct.monitors[1])
163+
164+
# 16-bit is normally TrueColor, but still just 16 bits.
165+
with (
166+
pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=16) as vdisplay,
167+
pytest.raises(ScreenShotError, match=r"\b16\b"),
158168
mss.mss(display=vdisplay.new_display_var, backend=backend) as sct,
159169
):
160170
sct.grab(sct.monitors[1])

src/tests/test_setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def test_sdist() -> None:
9797
f"mss-{__version__}/src/tests/test_setup.py",
9898
f"mss-{__version__}/src/tests/test_tools.py",
9999
f"mss-{__version__}/src/tests/test_windows.py",
100+
f"mss-{__version__}/src/tests/test_xcb.py",
100101
f"mss-{__version__}/src/tests/third_party/__init__.py",
101102
f"mss-{__version__}/src/tests/third_party/test_numpy.py",
102103
f"mss-{__version__}/src/tests/third_party/test_pil.py",

0 commit comments

Comments
 (0)