From 3cf6f43d4fc35c6d0c004c976964804ab45dd5df Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Mon, 20 Jul 2026 18:42:14 -0700 Subject: [PATCH] Add features to command-line regions, and add pytest parameter IDs This change is primarily to add the ability to use X11-style geometry specifications (WIDTHxHEIGHT+XOFF+YOFF) to the command line's `--coordinates` flag. (This is to make it easier for me to copy/paste geometry from `xwininfo` and other tools while I do some other experiments.) We add the ability to use negative top and left to refer to offsets from the bottom and right. This is available with comma-style coordinates too. We also split `__main__.py:main` into multiple parts, to reduce complexity and function length, and improve testability. We add tests for the new functionality. Finally, we add ID strings to several pytest parameterizations. By default, pytest uses just the string value for the arguments. This can be hard to use: for instance, `True` or `False` doesn't give you as much useful information as `cuda` and `cpu` do (in one parameter of the PyTorch test). We add parameter ID strings to boolean and lambda parameters. --- docs/source/release-history/v11.0.0.md | 6 + docs/source/usage.rst | 17 +- src/mss/__main__.py | 164 ++++++++++++++---- src/tests/test_buffer.py | 4 +- src/tests/test_compat_10_1.py | 6 +- src/tests/test_implementation.py | 65 ++++++- src/tests/test_xcb.py | 54 +++++- .../array_frameworks/test_torch_method.py | 17 +- 8 files changed, 267 insertions(+), 66 deletions(-) diff --git a/docs/source/release-history/v11.0.0.md b/docs/source/release-history/v11.0.0.md index c39c5e6e..91c1f1d2 100644 --- a/docs/source/release-history/v11.0.0.md +++ b/docs/source/release-history/v11.0.0.md @@ -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. diff --git a/docs/source/usage.rst b/docs/source/usage.rst index d0e1fa4c..216938f2 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -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 @@ -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). \ No newline at end of file diff --git a/src/mss/__main__.py b/src/mss/__main__.py index 4641b2ae..958a6a46 100644 --- a/src/mss/__main__.py +++ b/src/mss/__main__.py @@ -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() @@ -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-?[0-9]+)\s*,\s* + (?P-?[0-9]+)\s*,\s* + (?P[0-9]+)\s*,\s* + (?P[0-9]+)) + | + (?: # WIDTHxHEIGHT+XOFF+YOFF (X11 geometry style; see X(7)) + (?P[0-9]+)\s*x\s* + (?P[0-9]+)\s*(?P[+-])\s* + (?P[0-9]+)\s*(?P[+-])\s* + (?P[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) @@ -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", @@ -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: diff --git a/src/tests/test_buffer.py b/src/tests/test_buffer.py index cee852b1..651469ec 100644 --- a/src/tests/test_buffer.py +++ b/src/tests/test_buffer.py @@ -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: diff --git a/src/tests/test_compat_10_1.py b/src/tests/test_compat_10_1.py index 7981d27b..928d30d3 100644 --- a/src/tests/test_compat_10_1.py +++ b/src/tests/test_compat_10_1.py @@ -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: diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py index 29c4ce5e..66868f01 100644 --- a/src/tests/test_implementation.py +++ b/src/tests/test_implementation.py @@ -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 @@ -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() @@ -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.""" @@ -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) @@ -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" @@ -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: @@ -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 diff --git a/src/tests/test_xcb.py b/src/tests/test_xcb.py index 334fcc5c..e05b7958 100644 --- a/src/tests/test_xcb.py +++ b/src/tests/test_xcb.py @@ -316,15 +316,51 @@ def test_xgetimage_visual_validation_accepts_default_setup(visual_validation_env @pytest.mark.parametrize( ("mutator", "message"), [ - (lambda env: setattr(env.setup, "image_byte_order", xcb.ImageOrder.MSBFirst), "LSB-First"), - (lambda env: setattr(env.screen, "root_depth", 16), "color depth 24 or 32"), - (lambda env: setattr(env, "pixmap_formats", []), "supported formats"), - (lambda env: setattr(env.format, "bits_per_pixel", 16), "32 bpp"), - (lambda env: setattr(env.format, "scanline_pad", 16), "scanline padding"), - (lambda env: setattr(env, "depths", []), "supported depths"), - (lambda env: setattr(env, "visuals", []), "supported visuals"), - (lambda env: setattr(env.visual, "class_", xcb.VisualClass.StaticGray), "TrueColor"), - (lambda env: setattr(env.visual, "red_mask", 0), "BGRx ordering"), + pytest.param( + lambda env: setattr(env.setup, "image_byte_order", xcb.ImageOrder.MSBFirst), + "LSB-First", + id="image_byte_order", + ), + pytest.param( + lambda env: setattr(env.screen, "root_depth", 16), + "color depth 24 or 32", + id="root_depth", + ), + pytest.param( + lambda env: setattr(env, "pixmap_formats", []), + "supported formats", + id="pixmap_formats", + ), + pytest.param( + lambda env: setattr(env.format, "bits_per_pixel", 16), + "32 bpp", + id="bits_per_pixel", + ), + pytest.param( + lambda env: setattr(env.format, "scanline_pad", 16), + "scanline padding", + id="scanline_pad", + ), + pytest.param( + lambda env: setattr(env, "depths", []), + "supported depths", + id="depths", + ), + pytest.param( + lambda env: setattr(env, "visuals", []), + "supported visuals", + id="visuals", + ), + pytest.param( + lambda env: setattr(env.visual, "class_", xcb.VisualClass.StaticGray), + "TrueColor", + id="TrueColor", + ), + pytest.param( + lambda env: setattr(env.visual, "red_mask", 0), + "BGRx ordering", + id="BGRx", + ), ], ) def test_xgetimage_visual_validation_failures( diff --git a/src/tests/third_party/array_frameworks/test_torch_method.py b/src/tests/third_party/array_frameworks/test_torch_method.py index 7226b1e2..4b3eac96 100644 --- a/src/tests/third_party/array_frameworks/test_torch_method.py +++ b/src/tests/third_party/array_frameworks/test_torch_method.py @@ -35,14 +35,19 @@ def test_to_torch_dtype_uint8() -> None: @pytest.mark.parametrize("layout", ["HWC", "CHW"]) @pytest.mark.parametrize("channels", ["BGRA", "BGR", "RGBA", "RGB"]) -@pytest.mark.parametrize("cuda", [False, True]) -def test_to_torch_permutations(framework_test_image: ScreenShot, channels: str, layout: str, cuda: bool) -> None: - """Test all permutations of channels and layouts.""" - if cuda and not torch.cuda.is_available(): +@pytest.mark.parametrize( + "cuda", + [ + pytest.param(False, id="cpu"), # The CUDA versions won't be run in CI/CD, but it's still worth checking them on developers' machines if they # happen to have PyTorch with CUDA support. - pytest.skip("CUDA is not available") - + pytest.param( + True, id="cuda", marks=pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") + ), + ], +) +def test_to_torch_permutations(framework_test_image: ScreenShot, channels: str, layout: str, cuda: bool) -> None: + """Test all permutations of channels and layouts.""" uint8_target = reordered_test_image(channels=channels, layout=layout) bfloat16_target = uint8_target.astype(np.float32) / 255.0