Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions docs/source/release-history/v11.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,9 @@ See the documentation section {ref}`accessing_pixel_data` for details.
### General Improvements

The MSS context object will now always surface inner exceptions, even if `__exit__` may also generate an exception during tear-down.

## Command-line changes

When invoked from the command line, the `--coordinates` flag can specify coordinates in the format traditionally used by
X11: WIDTHxHEIGHT+LEFT+TOP. Additionally, whether using comma-style or X-style coordinates, a negative left or top can
be used to specify insets from the right or bottom edge of the specified monitor.
17 changes: 12 additions & 5 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,16 @@ Or via direct call from Python::

options:
-h, --help show this help message and exit
-c COORDINATES, --coordinates COORDINATES
the part of the screen to capture: top, left, width, height
-l {0,1,2,3,4,5,6,7,8,9}, --level {0,1,2,3,4,5,6,7,8,9}
-c, --coordinates COORDINATES
the part of the screen to capture:
TOP,LEFT,WIDTH,HEIGHT or WIDTHxHEIGHT+LEFT+TOP;
negative TOP or LEFT are insets from the bottom or
right edge
-l, --level {0,1,2,3,4,5,6,7,8,9}
the PNG compression level
-m MONITOR, --monitor MONITOR
-m, --monitor MONITOR
the monitor to screenshot
-o OUTPUT, --output OUTPUT
-o, --output OUTPUT
the output file name
-b, --backend BACKEND
platform-specific backend to use
Expand All @@ -317,3 +320,7 @@ Or via direct call from Python::

.. versionadded:: 10.2.0
``--backend`` to force selecting the backend to use.

.. 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).
164 changes: 129 additions & 35 deletions src/mss/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@

import os.path
import platform
import re
import sys
from argparse import ArgumentError, ArgumentParser
from argparse import ArgumentError, ArgumentParser, Namespace
from typing import Any

from mss import MSS, __version__
from mss.exception import ScreenShotError
from mss.tools import to_png

_COORDINATES_SYNTAX = "TOP,LEFT,WIDTH,HEIGHT or WIDTHxHEIGHT+LEFT+TOP"


def _backend_cli_choices() -> list[str]:
os_name = platform.system().lower()
Expand All @@ -29,8 +33,52 @@ def _backend_cli_choices() -> list[str]:
return ["default"]


def main(*args: str) -> int: # noqa: PLR0912
"""Main logic."""
def _parse_coordinates(coordinates: str) -> tuple[int, int, int, int]:
"""Parse a capture region string.

Supports ``TOP,LEFT,WIDTH,HEIGHT`` and X11 geometry style
``WIDTHxHEIGHT+LEFT+TOP`` (with optional ``-`` offsets).

:param coordinates: Region string to parse.
:returns: Parsed coordinates as ``(top, left, width, height)``.
:raises ValueError: If *coordinates* does not match a supported
syntax.
"""
match_res = re.fullmatch(
r"""(?x)^\s*(?:
(?: # top, left, width, height
(?P<top1>-?[0-9]+)\s*,\s*
(?P<left1>-?[0-9]+)\s*,\s*
(?P<width1>[0-9]+)\s*,\s*
(?P<height1>[0-9]+))
|
(?: # WIDTHxHEIGHT+XOFF+YOFF (X11 geometry style; see X(7))
(?P<width2>[0-9]+)\s*x\s*
(?P<height2>[0-9]+)\s*(?P<left2sign>[+-])\s*
(?P<left2>[0-9]+)\s*(?P<top2sign>[+-])\s*
(?P<top2>[0-9]+))
)\s*$""",
coordinates,
)
if match_res is None:
msg = f"Coordinates syntax: {_COORDINATES_SYNTAX}"
raise ValueError(msg)
if match_res["top1"] is not None:
return (int(match_res["top1"]), int(match_res["left1"]), int(match_res["width1"]), int(match_res["height1"]))
if match_res["top2"] is not None:
top2 = int(match_res["top2"])
if match_res["top2sign"] == "-":
top2 = -top2
left2 = int(match_res["left2"])
if match_res["left2sign"] == "-":
left2 = -left2
return top2, left2, int(match_res["width2"]), int(match_res["height2"])
msg = f"Coordinates syntax: {_COORDINATES_SYNTAX}"
raise ValueError(msg)


def _build_parser() -> ArgumentParser:
"""Create and configure the CLI argument parser."""
backend_choices = _backend_cli_choices()

cli_args = ArgumentParser(prog="mss", exit_on_error=False)
Expand All @@ -39,7 +87,10 @@ def main(*args: str) -> int: # noqa: PLR0912
"--coordinates",
default="",
type=str,
help="the part of the screen to capture: top, left, width, height",
help=(
"the part of the screen to capture: TOP,LEFT,WIDTH,HEIGHT or WIDTHxHEIGHT+LEFT+TOP; "
"negative TOP or LEFT are insets from the bottom or right edge"
),
)
cli_args.add_argument(
"-l",
Expand All @@ -63,53 +114,96 @@ def main(*args: str) -> int: # noqa: PLR0912
"-b", "--backend", default="default", choices=backend_choices, help="platform-specific backend to use"
)
cli_args.add_argument("-v", "--version", action="version", version=__version__)
return cli_args


def _prepare_grab_options(options: Namespace) -> tuple[int, str, dict[str, int] | None]:
"""Build grab options derived from parsed CLI arguments."""
monitor_index = int(options.monitor)
output_template = str(options.output)
if not options.coordinates:
return monitor_index, output_template, None

top, left, width, height = _parse_coordinates(str(options.coordinates))
coordinates = {
"top": int(top),
"left": int(left),
"width": int(width),
"height": int(height),
}
if options.output == "monitor-{mon}.png":
output_template = "sct-{top}x{left}_{width}x{height}.png"
return monitor_index, output_template, coordinates


def _build_mss_kwargs(options: Namespace) -> dict[str, Any]:
"""Build keyword arguments passed to ``MSS`` constructor."""
mss_kwargs: dict[str, str | bool] = {"backend": options.backend}
if options.with_cursor is not None:
mss_kwargs["with_cursor"] = options.with_cursor
return mss_kwargs


def _capture_and_save(
sct: MSS,
*,
options: Namespace,
monitor_index: int,
output_template: str,
coordinates: dict[str, int] | None,
) -> None:
"""Capture screenshots and write output files."""
if coordinates is not None:
if coordinates["top"] < 0:
coordinates["top"] = sct.monitors[monitor_index]["height"] + coordinates["top"]
if coordinates["left"] < 0:
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)
if not options.quiet:
print(os.path.realpath(output))
return

for file_name in sct.save(mon=monitor_index, output=output_template):
if not options.quiet:
print(os.path.realpath(file_name))


def main(*args: str) -> int:
"""Main logic."""
cli_args = _build_parser()

try:
options = cli_args.parse_args(args or None)
except ArgumentError as e:
# By default, parse_args will print and the error and exit. We
# return instead of exiting, to make unit testing easier.
# By default, parse_args will print and the error and exit. We return instead of exiting, to make unit testing
# easier.
cli_args.print_usage(sys.stderr)
print(f"{cli_args.prog}: error: {e}", file=sys.stderr)
return 2
grab_kwargs = {"mon": options.monitor, "output": options.output}
if options.coordinates:
try:
top, left, width, height = options.coordinates.split(",")
except ValueError:
print("Coordinates syntax: top, left, width, height")
return 2

grab_kwargs["mon"] = {
"top": int(top),
"left": int(left),
"width": int(width),
"height": int(height),
}
if options.output == "monitor-{mon}.png":
grab_kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png"
try:
monitor_index, output_template, coordinates = _prepare_grab_options(options)
except ValueError:
print(f"Coordinates syntax: {_COORDINATES_SYNTAX}")
return 2

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.", file=sys.stderr)
options.with_cursor = None

mss_kwargs = {"backend": options.backend}
if options.with_cursor is not None:
mss_kwargs["with_cursor"] = options.with_cursor
mss_kwargs = _build_mss_kwargs(options)

try:
with MSS(**mss_kwargs) as sct:
if options.coordinates:
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(**grab_kwargs):
if not options.quiet:
print(os.path.realpath(file_name))
_capture_and_save(
sct,
options=options,
monitor_index=monitor_index,
output_template=output_template,
coordinates=coordinates,
)
return 0
except ScreenShotError:
if options.quiet:
Expand Down
4 changes: 2 additions & 2 deletions src/tests/test_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ def finalizer() -> None:
@pytest.mark.parametrize(
("buffer_class", "readonly"),
[
(bytearray, False),
(bytes, True),
pytest.param(bytearray, False, id="bytearray"),
pytest.param(bytes, True, id="bytes"),
],
)
def test_finalizing_buffer_preserves_readonly(buffer_class: type, readonly: bool) -> None:
Expand Down
6 changes: 3 additions & 3 deletions src/tests/test_compat_10_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ def _platform_factory_from_import_style() -> type[MSS]:
@pytest.mark.parametrize(
"factory_getter",
[
lambda: mss.mss,
_factory_from_import_style,
_factory_from_module_style,
pytest.param(lambda: mss.mss, id="mss.mss"),
pytest.param(_factory_from_import_style, id="import_style"),
pytest.param(_factory_from_module_style, id="module_style"),
],
)
def test_mss_factory_documented_styles_return_mss(factory_getter: MSSFactoryGetter) -> None:
Expand Down
65 changes: 59 additions & 6 deletions src/tests/test_implementation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import pytest

import mss
from mss.__main__ import _parse_coordinates
from mss.__main__ import main as entry_point
from mss.base import MSS, MSSImplementation
from mss.exception import ScreenShotError
Expand Down Expand Up @@ -75,7 +76,14 @@ def close(self) -> None:
raise self.close_error


@pytest.mark.parametrize("cls", [MSS0, MSS1, MSS2])
@pytest.mark.parametrize(
"cls",
[
pytest.param(MSS0, id="no_methods"),
pytest.param(MSS1, id="grab_only"),
pytest.param(MSS2, id="monitors_only"),
],
)
def test_incomplete_class(cls: type[MSSImplementation]) -> None:
with pytest.raises(TypeError):
cls()
Expand Down Expand Up @@ -144,7 +152,13 @@ def reset_sys_argv(monkeypatch: pytest.MonkeyPatch) -> None:


@pytest.mark.usefixtures("reset_sys_argv")
@pytest.mark.parametrize("with_cursor", [False, True])
@pytest.mark.parametrize(
"with_cursor",
[
pytest.param(False, id="without_cursor"),
pytest.param(True, id="with_cursor"),
],
)
class TestEntryPoint:
"""CLI entry-point scenarios split into focused tests."""

Expand Down Expand Up @@ -207,8 +221,8 @@ def test_output_pattern_with_date(self, with_cursor: bool, capsys: pytest.Captur
assert filename.is_file()
filename.unlink()

def test_coordinates_capture(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None:
coordinates = "2,12,40,67"
@pytest.mark.parametrize("coordinates", ["2,12,40,67", "40x67+12+2"])
def test_coordinates_capture(self, with_cursor: bool, capsys: pytest.CaptureFixture, coordinates: str) -> None:
filename = Path("sct-2x12_40x67.png")
for opt in ("-c", "--coordinates"):
self._run_main(with_cursor, opt, coordinates)
Expand All @@ -222,7 +236,7 @@ def test_invalid_coordinates(self, with_cursor: bool, capsys: pytest.CaptureFixt
for opt in ("-c", "--coordinates"):
self._run_main(with_cursor, opt, coordinates, ret=2)
captured = capsys.readouterr()
assert captured.out == "Coordinates syntax: top, left, width, height\n"
assert captured.out == "Coordinates syntax: TOP,LEFT,WIDTH,HEIGHT or WIDTHxHEIGHT+LEFT+TOP\n"

def test_backend_option(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None:
backend = "default"
Expand All @@ -243,7 +257,13 @@ 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.MSS.monitors", new=[])
@pytest.mark.parametrize("quiet", [False, True])
@pytest.mark.parametrize(
"quiet",
[
pytest.param(False, id="verbose_mode"),
pytest.param(True, id="quiet_mode"),
],
)
def test_entry_point_error(quiet: bool, capsys: pytest.CaptureFixture) -> None:
def main(*args: str) -> int:
if quiet:
Expand Down Expand Up @@ -275,6 +295,39 @@ def test_entry_point_with_no_argument(capsys: pytest.CaptureFixture) -> None:
assert "usage: mss" in captured.out


@pytest.mark.parametrize(
("coordinates", "expected"),
[
pytest.param(" 15,14,0012,13 ", (15, 14, 12, 13), id="comma_pos_top_pos_left"),
pytest.param("-15, 0014,12,0013", (-15, 14, 12, 13), id="comma_neg_top_pos_left"),
pytest.param("0015 , -14 , 12 , 13", (15, -14, 12, 13), id="comma_pos_top_neg_left"),
pytest.param(" -0015,-14,12,0013 ", (-15, -14, 12, 13), id="comma_neg_top_neg_left"),
pytest.param("12x13+14+15", (15, 14, 12, 13), id="x_pos_top_pos_left"),
pytest.param(" 0012 x 13 - 14 + 15 ", (15, -14, 12, 13), id="x_pos_top_neg_left"),
pytest.param("12x0013+0014-15", (-15, 14, 12, 13), id="x_neg_top_pos_left"),
pytest.param(" 12 x 13 - 0014 - 0015 ", (-15, -14, 12, 13), id="x_neg_top_neg_left"),
],
)
def test_parse_coordinates_valid(coordinates: str, expected: tuple[int, int, int, int]) -> None:
assert _parse_coordinates(coordinates) == expected


@pytest.mark.parametrize(
"coordinates",
[
pytest.param("1,2,-3,4", id="comma_negative_width"),
pytest.param("1,2,3,-4", id="comma_negative_height"),
pytest.param("-0012x0013+0014+0015", id="x_negative_width"),
pytest.param("0012x-0013+0014+0015", id="x_negative_height"),
pytest.param("0x10,2,30,40", id="comma_hex_prefix"),
pytest.param("30x40+0x10+2", id="x_hex_prefix"),
],
)
def test_parse_coordinates_invalid(coordinates: str) -> None:
with pytest.raises(ValueError, match=r"(?i)coordinates syntax"):
_parse_coordinates(coordinates)


def test_grab_with_tuple(mss_impl: Callable[..., MSS]) -> None:
left = 100
top = 100
Expand Down
Loading