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