diff --git a/CHANGES.md b/CHANGES.md index cda211e9..74021f68 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,17 @@ # Technical Changes +## 10.0.0 (2024-11-xx) + +### base.py +- Added `OPAQUE` + +### darwin.py +- Added `MAC_VERSION_CATALINA` + +### linux.py +- Added `BITS_PER_PIXELS_32` +- Added `SUPPORTED_BITS_PER_PIXELS` + ## 9.0.0 (2023-04-18) ### linux.py diff --git a/docs/source/examples/callback.py b/docs/source/examples/callback.py index cb644436..5a93d122 100644 --- a/docs/source/examples/callback.py +++ b/docs/source/examples/callback.py @@ -4,18 +4,18 @@ Screenshot of the monitor 1, with callback. """ -import os -import os.path +from pathlib import Path import mss def on_exists(fname: str) -> None: """Callback example when we try to overwrite an existing screenshot.""" - if os.path.isfile(fname): - newfile = f"{fname}.old" - print(f"{fname} -> {newfile}") - os.rename(fname, newfile) + file = Path(fname) + if file.is_file(): + newfile = file.with_name(f"{file.name}.old") + print(f"{fname} → {newfile}") + file.rename(newfile) with mss.mss() as sct: diff --git a/pyproject.toml b/pyproject.toml index 94746c11..1f5fc2d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,28 +152,34 @@ line-length = 120 indent-width = 4 target-version = "py39" -[tool.ruff.lint] -extend-select = ["ALL"] -ignore = [ - "ANN101", - "ANN401", - "C90", - "COM812", - "D", # TODO - "ERA", - "FBT", - "INP001", - "ISC001", - "PTH", - "PL", - "S", - "SLF", - "T201", -] -fixable = ["ALL"] - [tool.ruff.format] quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" + +[tool.ruff.lint] +fixable = ["ALL"] +extend-select = ["ALL"] +ignore = [ + "ANN401", # typing.Any + "C90", # complexity + "COM812", # conflict + "D", # TODO + "ISC001", # conflict + "T201", # `print()` +] + +[tool.ruff.lint.per-file-ignores] +"docs/source/*" = [ + "ERA001", # commented code + "INP001", # file `xxx` is part of an implicit namespace package +] +"src/tests/*" = [ + "FBT001", # boolean-typed positional argument in function definition + "PLR2004", # magic value used in comparison + "S101", # use of `assert` detected + "S602", # `subprocess` call with `shell=True` + "S603", # `subprocess` call + "SLF001", # private member accessed +] diff --git a/src/mss/base.py b/src/mss/base.py index cf588d0d..8a7397f5 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -28,6 +28,8 @@ lock = Lock() +OPAQUE = 255 + class MSSBase(metaclass=ABCMeta): """This class will be overloaded by a system specific one.""" @@ -200,7 +202,6 @@ def shot(self, /, **kwargs: Any) -> str: @staticmethod def _merge(screenshot: ScreenShot, cursor: ScreenShot, /) -> ScreenShot: """Create composite image by blending screenshot and mouse cursor.""" - (cx, cy), (cw, ch) = cursor.pos, cursor.size (x, y), (w, h) = screenshot.pos, screenshot.size @@ -234,7 +235,7 @@ def _merge(screenshot: ScreenShot, cursor: ScreenShot, /) -> ScreenShot: if not alpha: continue - if alpha == 255: + if alpha == OPAQUE: screen_raw[spos : spos + 3] = cursor_raw[cpos : cpos + 3] else: alpha2 = alpha / 255 diff --git a/src/mss/darwin.py b/src/mss/darwin.py index 6d4fa289..a56e05a8 100644 --- a/src/mss/darwin.py +++ b/src/mss/darwin.py @@ -20,6 +20,8 @@ __all__ = ("MSS",) +MAC_VERSION_CATALINA = 10.16 + def cgfloat() -> type[c_double | c_float]: """Get the appropriate value for a float.""" @@ -59,7 +61,7 @@ def __repr__(self) -> str: # # Note: keep it sorted by cfunction. CFUNCTIONS: CFunctions = { - # cfunction: (attr, argtypes, restype) + # Syntax: cfunction: (attr, argtypes, restype) "CGDataProviderCopyData": ("core", [c_void_p], c_void_p), "CGDisplayBounds": ("core", [c_uint32], CGRect), "CGDisplayRotation": ("core", [c_uint32], c_float), @@ -98,7 +100,7 @@ def __init__(self, /, **kwargs: Any) -> None: def _init_library(self) -> None: """Load the CoreGraphics library.""" version = float(".".join(mac_ver()[0].split(".")[:2])) - if version < 10.16: + if version < MAC_VERSION_CATALINA: coregraphics = ctypes.util.find_library("CoreGraphics") else: # macOS Big Sur and newer @@ -136,9 +138,13 @@ def _monitors_impl(self) -> None: rect = core.CGDisplayBounds(display) rect = core.CGRectStandardize(rect) width, height = rect.size.width, rect.size.height + + # 0.0: normal + # 90.0: right + # -90.0: left if core.CGDisplayRotation(display) in {90.0, -90.0}: - # {0.0: "normal", 90.0: "right", -90.0: "left"} width, height = height, width + self._monitors.append( { "left": int_(rect.origin.x), @@ -161,7 +167,6 @@ def _monitors_impl(self) -> None: def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: """Retrieve all pixels from a monitor. Pixels have to be RGB.""" - core = self.core rect = CGRect((monitor["left"], monitor["top"]), (monitor["width"], monitor["height"])) diff --git a/src/mss/factory.py b/src/mss/factory.py index 83ea0d32..b0793e8c 100644 --- a/src/mss/factory.py +++ b/src/mss/factory.py @@ -19,7 +19,6 @@ def mss(**kwargs: Any) -> MSSBase: It then proxies its arguments to the class for instantiation. """ - os_ = platform.system().lower() if os_ == "darwin": diff --git a/src/mss/linux.py b/src/mss/linux.py index 6dac52b8..d357e3be 100644 --- a/src/mss/linux.py +++ b/src/mss/linux.py @@ -42,6 +42,10 @@ PLAINMASK = 0x00FFFFFF ZPIXMAP = 2 +BITS_PER_PIXELS_32 = 32 +SUPPORTED_BITS_PER_PIXELS = { + BITS_PER_PIXELS_32, +} class Display(Structure): @@ -233,7 +237,7 @@ def _validate(retval: int, func: Any, args: tuple[Any, Any], /) -> tuple[Any, An # # Note: keep it sorted by cfunction. CFUNCTIONS: CFunctions = { - # cfunction: (attr, argtypes, restype) + # Syntax: cfunction: (attr, argtypes, restype) "XCloseDisplay": ("xlib", [POINTER(Display)], c_void_p), "XDefaultRootWindow": ("xlib", [POINTER(Display)], POINTER(XWindowAttributes)), "XDestroyImage": ("xlib", [POINTER(XImage)], c_void_p), @@ -433,7 +437,7 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: try: bits_per_pixel = ximage.contents.bits_per_pixel - if bits_per_pixel != 32: + if bits_per_pixel not in SUPPORTED_BITS_PER_PIXELS: msg = f"[XImage] bits per pixel value not (yet?) implemented: {bits_per_pixel}." raise ScreenShotError(msg) diff --git a/src/mss/tools.py b/src/mss/tools.py index 2383665f..9eb8b6f7 100644 --- a/src/mss/tools.py +++ b/src/mss/tools.py @@ -7,9 +7,13 @@ import os import struct import zlib +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from pathlib import Path -def to_png(data: bytes, size: tuple[int, int], /, *, level: int = 6, output: str | None = None) -> bytes | None: + +def to_png(data: bytes, size: tuple[int, int], /, *, level: int = 6, output: Path | str | None = None) -> bytes | None: """Dump data to a PNG file. If `output` is `None`, create no file but return the whole PNG data. @@ -18,7 +22,6 @@ def to_png(data: bytes, size: tuple[int, int], /, *, level: int = 6, output: str :param int level: PNG compression level. :param str output: Output file name. """ - pack = struct.pack crc32 = zlib.crc32 @@ -49,7 +52,7 @@ def to_png(data: bytes, size: tuple[int, int], /, *, level: int = 6, output: str # Returns raw bytes of the whole PNG data return magic + b"".join(ihdr + idat + iend) - with open(output, "wb") as fileh: + with open(output, "wb") as fileh: # noqa: PTH123 fileh.write(magic) fileh.write(b"".join(ihdr)) fileh.write(b"".join(idat)) diff --git a/src/mss/windows.py b/src/mss/windows.py index 0f41cd62..7a3a78f5 100644 --- a/src/mss/windows.py +++ b/src/mss/windows.py @@ -74,7 +74,7 @@ class BITMAPINFO(Structure): # # Note: keep it sorted by cfunction. CFUNCTIONS: CFunctions = { - # cfunction: (attr, argtypes, restype) + # Syntax: cfunction: (attr, argtypes, restype) "BitBlt": ("gdi32", [HDC, INT, INT, INT, INT, HDC, INT, INT, DWORD], BOOL), "CreateCompatibleBitmap": ("gdi32", [HDC, INT, INT], HBITMAP), "CreateCompatibleDC": ("gdi32", [HDC], HDC), @@ -179,7 +179,6 @@ def _callback(_monitor: int, _data: HDC, rect: LPRECT, _dc: LPARAM) -> int: """Callback for monitorenumproc() function, it will return a RECT with appropriate values. """ - rct = rect.contents self._monitors.append( { diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 0a30eb85..5d455821 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -2,11 +2,9 @@ Source: https://github.com/BoboTiG/python-mss. """ -import glob -import os import platform from collections.abc import Generator -from hashlib import md5 +from hashlib import sha256 from pathlib import Path from zipfile import ZipFile @@ -28,13 +26,13 @@ def _no_warnings(recwarn: pytest.WarningsRecorder) -> Generator: def purge_files() -> None: """Remove all generated files from previous runs.""" - for fname in glob.glob("*.png"): - print(f"Deleting {fname!r} ...") - os.unlink(fname) + for file in Path().glob("*.png"): + print(f"Deleting {file} ...") + file.unlink() - for fname in glob.glob("*.png.old"): - print(f"Deleting {fname!r} ...") - os.unlink(fname) + for file in Path().glob("*.png.old"): + print(f"Deleting {file} ...") + file.unlink() @pytest.fixture(scope="module", autouse=True) @@ -48,7 +46,7 @@ def raw() -> bytes: with ZipFile(file) as fh: data = fh.read(file.with_suffix("").name) - assert md5(data).hexdigest() == "125696266e2a8f5240f6bc17e4df98c6" + assert sha256(data).hexdigest() == "d86ed4366d5a882cfe1345de82c87b81aef9f9bf085f4c42acb6f63f3967eccd" return data diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py index 146d336f..5672f044 100644 --- a/src/tests/test_implementation.py +++ b/src/tests/test_implementation.py @@ -9,6 +9,7 @@ import platform import sys from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import Mock, patch @@ -104,24 +105,25 @@ def main(*args: str, ret: int = 0) -> None: main() captured = capsys.readouterr() for mon, line in enumerate(captured.out.splitlines(), 1): - filename = f"monitor-{mon}.png" - assert line.endswith(filename) - assert os.path.isfile(filename) - os.remove(filename) + filename = Path(f"monitor-{mon}.png") + assert line.endswith(filename.name) + assert filename.is_file() + filename.unlink() + file = Path("monitor-1.png") for opt in ("-m", "--monitor"): main(opt, "1") captured = capsys.readouterr() - assert captured.out.endswith("monitor-1.png\n") - assert os.path.isfile("monitor-1.png") - os.remove("monitor-1.png") + assert captured.out.endswith(f"{file.name}\n") + assert filename.is_file() + filename.unlink() for opts in zip(["-m 1", "--monitor=1"], ["-q", "--quiet"]): main(*opts) captured = capsys.readouterr() assert not captured.out - assert os.path.isfile("monitor-1.png") - os.remove("monitor-1.png") + assert filename.is_file() + filename.unlink() fmt = "sct-{mon}-{width}x{height}.png" for opt in ("-o", "--out"): @@ -129,28 +131,28 @@ def main(*args: str, ret: int = 0) -> None: captured = capsys.readouterr() with mss.mss(display=os.getenv("DISPLAY")) as sct: for mon, (monitor, line) in enumerate(zip(sct.monitors[1:], captured.out.splitlines()), 1): - filename = fmt.format(mon=mon, **monitor) - assert line.endswith(filename) - assert os.path.isfile(filename) - os.remove(filename) + filename = Path(fmt.format(mon=mon, **monitor)) + assert line.endswith(filename.name) + assert filename.is_file() + filename.unlink() fmt = "sct_{mon}-{date:%Y-%m-%d}.png" for opt in ("-o", "--out"): main("-m 1", opt, fmt) - filename = fmt.format(mon=1, date=datetime.now(tz=UTC)) + filename = Path(fmt.format(mon=1, date=datetime.now(tz=UTC))) captured = capsys.readouterr() - assert captured.out.endswith(filename + "\n") - assert os.path.isfile(filename) - os.remove(filename) + assert captured.out.endswith(f"{filename}\n") + assert filename.is_file() + filename.unlink() coordinates = "2,12,40,67" - filename = "sct-2x12_40x67.png" + filename = Path("sct-2x12_40x67.png") for opt in ("-c", "--coordinates"): main(opt, coordinates) captured = capsys.readouterr() - assert captured.out.endswith(filename + "\n") - assert os.path.isfile(filename) - os.remove(filename) + assert captured.out.endswith(f"{filename}\n") + assert filename.is_file() + filename.unlink() coordinates = "2,12,40" for opt in ("-c", "--coordinates"): diff --git a/src/tests/test_save.py b/src/tests/test_save.py index a8d6e385..9597206c 100644 --- a/src/tests/test_save.py +++ b/src/tests/test_save.py @@ -4,6 +4,7 @@ import os.path from datetime import datetime +from pathlib import Path import pytest @@ -26,33 +27,33 @@ def test_at_least_2_monitors() -> None: def test_files_exist() -> None: with mss(display=os.getenv("DISPLAY")) as sct: for filename in sct.save(): - assert os.path.isfile(filename) + assert Path(filename).is_file() - assert os.path.isfile(sct.shot()) + assert Path(sct.shot()).is_file() sct.shot(mon=-1, output="fullscreen.png") - assert os.path.isfile("fullscreen.png") + assert Path("fullscreen.png").is_file() def test_callback() -> None: def on_exists(fname: str) -> None: - if os.path.isfile(fname): - new_file = f"{fname}.old" - os.rename(fname, new_file) + file = Path(fname) + if Path(file).is_file(): + file.rename(f"{file.name}.old") with mss(display=os.getenv("DISPLAY")) as sct: filename = sct.shot(mon=0, output="mon0.png", callback=on_exists) - assert os.path.isfile(filename) + assert Path(filename).is_file() filename = sct.shot(output="mon1.png", callback=on_exists) - assert os.path.isfile(filename) + assert Path(filename).is_file() def test_output_format_simple() -> None: with mss(display=os.getenv("DISPLAY")) as sct: filename = sct.shot(mon=1, output="mon-{mon}.png") assert filename == "mon-1.png" - assert os.path.isfile(filename) + assert Path(filename).is_file() def test_output_format_positions_and_sizes() -> None: @@ -60,7 +61,7 @@ def test_output_format_positions_and_sizes() -> None: with mss(display=os.getenv("DISPLAY")) as sct: filename = sct.shot(mon=1, output=fmt) assert filename == fmt.format(**sct.monitors[1]) - assert os.path.isfile(filename) + assert Path(filename).is_file() def test_output_format_date_simple() -> None: @@ -68,7 +69,7 @@ def test_output_format_date_simple() -> None: with mss(display=os.getenv("DISPLAY")) as sct: try: filename = sct.shot(mon=1, output=fmt) - assert os.path.isfile(filename) + assert Path(filename).is_file() except OSError: # [Errno 22] invalid mode ('wb') or filename: 'sct_1-2019-01-01 21:20:43.114194.png' pytest.mark.xfail("Default date format contains ':' which is not allowed.") @@ -79,4 +80,4 @@ def test_output_format_date_custom() -> None: with mss(display=os.getenv("DISPLAY")) as sct: filename = sct.shot(mon=1, output=fmt) assert filename == fmt.format(date=datetime.now(tz=UTC)) - assert os.path.isfile(filename) + assert Path(filename).is_file() diff --git a/src/tests/test_setup.py b/src/tests/test_setup.py index 5fd2f81d..d4788677 100644 --- a/src/tests/test_setup.py +++ b/src/tests/test_setup.py @@ -74,6 +74,7 @@ def test_sdist() -> None: f"mss-{__version__}/src/mss/screenshot.py", f"mss-{__version__}/src/mss/tools.py", f"mss-{__version__}/src/mss/windows.py", + f"mss-{__version__}/src/tests/__init__.py", f"mss-{__version__}/src/tests/bench_bgra2rgb.py", f"mss-{__version__}/src/tests/bench_general.py", f"mss-{__version__}/src/tests/conftest.py", diff --git a/src/tests/test_tools.py b/src/tests/test_tools.py index ff742e88..a1494833 100644 --- a/src/tests/test_tools.py +++ b/src/tests/test_tools.py @@ -5,6 +5,7 @@ import hashlib import os.path import zlib +from pathlib import Path import pytest @@ -13,7 +14,7 @@ WIDTH = 10 HEIGHT = 10 -MD5SUM = "055e615b74167c9bdfea16a00539450c" +MD5SUM = "ee1b645cc989cbfc48e613b395a929d3d79a922b77b9b38e46647ff6f74acef5" def test_bad_compression_level() -> None: @@ -23,50 +24,48 @@ def test_bad_compression_level() -> None: def test_compression_level() -> None: data = b"rgb" * WIDTH * HEIGHT - output = f"{WIDTH}x{HEIGHT}.png" + output = Path(f"{WIDTH}x{HEIGHT}.png") with mss(display=os.getenv("DISPLAY")) as sct: to_png(data, (WIDTH, HEIGHT), level=sct.compression_level, output=output) - with open(output, "rb") as png: - assert hashlib.md5(png.read()).hexdigest() == MD5SUM + assert hashlib.sha256(output.read_bytes()).hexdigest() == MD5SUM @pytest.mark.parametrize( ("level", "checksum"), [ - (0, "f37123dbc08ed7406d933af11c42563e"), - (1, "7d5dcf2a2224445daf19d6d91cf31cb5"), - (2, "bde05376cf51cf951e26c31c5f55e9d5"), - (3, "3d7e73c2a9c2d8842b363eeae8085919"), - (4, "9565a5caf89a9221459ee4e02b36bf6e"), - (5, "4d722e21e7d62fbf1e3154de7261fc67"), - (6, "055e615b74167c9bdfea16a00539450c"), - (7, "4d88d3f5923b6ef05b62031992294839"), - (8, "4d88d3f5923b6ef05b62031992294839"), - (9, "4d88d3f5923b6ef05b62031992294839"), + (0, "547191069e78eef1c5899f12c256dd549b1338e67c5cd26a7cbd1fc5a71b83aa"), + (1, "841665ec73b641dfcafff5130b497f5c692ca121caeb06b1d002ad3de5c77321"), + (2, "b11107163207f68f36294deb3f8e6b6a5a11399a532917bdd59d1d5f1117d4d0"), + (3, "31278bad8c1c077c715ac4f3b497694a323a71a87c5ff8bdc7600a36bd8d8c96"), + (4, "8f7237e1394d9ddc71fcb1fa4a2c2953087562ef6eac85d32d8154b61b287fb0"), + (5, "83a55f161bad2d511b222dcd32059c9adf32c3238b65f9aa576f19bc0a6c8fec"), + (6, "ee1b645cc989cbfc48e613b395a929d3d79a922b77b9b38e46647ff6f74acef5"), + (7, "85f8d1b01cef926c111b194229bd6c01e2a65b18b4dd902293698e6de8f4e9ac"), + (8, "85f8d1b01cef926c111b194229bd6c01e2a65b18b4dd902293698e6de8f4e9ac"), + (9, "85f8d1b01cef926c111b194229bd6c01e2a65b18b4dd902293698e6de8f4e9ac"), ], ) def test_compression_levels(level: int, checksum: str) -> None: data = b"rgb" * WIDTH * HEIGHT raw = to_png(data, (WIDTH, HEIGHT), level=level) assert isinstance(raw, bytes) - md5 = hashlib.md5(raw).hexdigest() - assert md5 == checksum + sha256 = hashlib.sha256(raw).hexdigest() + assert sha256 == checksum def test_output_file() -> None: data = b"rgb" * WIDTH * HEIGHT - output = f"{WIDTH}x{HEIGHT}.png" + output = Path(f"{WIDTH}x{HEIGHT}.png") to_png(data, (WIDTH, HEIGHT), output=output) - assert os.path.isfile(output) - with open(output, "rb") as png: - assert hashlib.md5(png.read()).hexdigest() == MD5SUM + assert output.is_file() + assert hashlib.sha256(output.read_bytes()).hexdigest() == MD5SUM def test_output_raw_bytes() -> None: data = b"rgb" * WIDTH * HEIGHT raw = to_png(data, (WIDTH, HEIGHT)) assert isinstance(raw, bytes) - assert hashlib.md5(raw).hexdigest() == MD5SUM + assert hashlib.sha256(raw).hexdigest() == MD5SUM diff --git a/src/tests/third_party/test_pil.py b/src/tests/third_party/test_pil.py index a7d3b7be..a3194485 100644 --- a/src/tests/third_party/test_pil.py +++ b/src/tests/third_party/test_pil.py @@ -5,6 +5,7 @@ import itertools import os import os.path +from pathlib import Path import pytest @@ -26,8 +27,9 @@ def test_pil() -> None: for x, y in itertools.product(range(width), range(height)): assert img.getpixel((x, y)) == sct_img.pixel(x, y) - img.save("box.png") - assert os.path.isfile("box.png") + output = Path("box.png") + img.save(output) + assert output.is_file() def test_pil_bgra() -> None: @@ -43,8 +45,9 @@ def test_pil_bgra() -> None: for x, y in itertools.product(range(width), range(height)): assert img.getpixel((x, y)) == sct_img.pixel(x, y) - img.save("box-bgra.png") - assert os.path.isfile("box-bgra.png") + output = Path("box-bgra.png") + img.save(output) + assert output.is_file() def test_pil_not_16_rounded() -> None: @@ -60,5 +63,6 @@ def test_pil_not_16_rounded() -> None: for x, y in itertools.product(range(width), range(height)): assert img.getpixel((x, y)) == sct_img.pixel(x, y) - img.save("box.png") - assert os.path.isfile("box.png") + output = Path("box.png") + img.save(output) + assert output.is_file()