diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..f3c035c6 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,23 @@ +{ + /* We use python:3 because it's based on Debian, which has libxcb-errors0 available. The default universal image is + * based on Ubuntu, which doesn't. */ + "image": "mcr.microsoft.com/devcontainers/python:3", + "features": { + "ghcr.io/devcontainers-extra/features/apt-get-packages:1": { + "packages": [ + /* Needed for MSS generally */ + "libxfixes3", + /* Needed for testing */ + "xvfb", "xauth", + /* Improves error messages */ + "libxcb-errors0", + /* We include the gdb stuff to troubleshoot when ctypes stuff goes off the rails. */ + "debuginfod", "gdb", + /* GitHub checks out the repo with git-lfs configured. */ + "git-lfs" + ], + "preserve_apt_list": true + } + }, + "postCreateCommand": "echo set debuginfod enabled on | sudo tee /etc/gdb/gdbinit.d/debuginfod.gdb" +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 204c47d0..693d1704 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,12 +1 @@ -# These are supported funding model platforms - github: BoboTiG -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: BoboTiG -otechie: # Replace with a single Otechie username -custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 00a7e0b6..2c7c4fcb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,9 @@ ### Changes proposed in this PR -- Fixes # -- ... -- ... - -It is **very** important to keep up to date tests and documentation. +Fixes # +(...) - [ ] Tests added/updated - [ ] Documentation updated - -Is your code right? - -- [ ] PEP8 compliant -- [ ] `flake8` passed +- [ ] Changelog entry added +- [ ] `./check.sh` passed diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4075752b..8d9e0b26 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,7 @@ updates: - package-ecosystem: github-actions directory: / schedule: - interval: daily + interval: weekly labels: - dependencies - QA/CI @@ -13,7 +13,7 @@ updates: - package-ecosystem: pip directory: / schedule: - interval: daily + interval: weekly assignees: - BoboTiG labels: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 871822d8..1a99a0c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" cache: pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8b0b4f45..72ebebd0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,19 +1,24 @@ name: Tests on: - push: - branches: - - main pull_request: workflow_dispatch: +permissions: + contents: write + pull-requests: write + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name != 'pull_request' && github.sha || '' }} + cancel-in-progress: true + jobs: quality: name: Quality runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: "3.x" cache: pip @@ -21,23 +26,23 @@ jobs: run: | python -m pip install -U pip python -m pip install -e '.[dev]' - - name: Tests + - name: Check run: ./check.sh documentation: name: Documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: "3.x" cache: pip - - name: Install test dependencies + - name: Install dependencies run: | - python -m pip install -U pip - python -m pip install -e '.[test]' - - name: Tests + python -m pip install -U pip + python -m pip install -e '.[docs]' + - name: Build run: | sphinx-build -d docs docs/source docs_out --color -W -bhtml @@ -55,8 +60,6 @@ jobs: - emoji: 🪟 runs-on: [windows-latest] python: - - name: CPython 3.8 - runs-on: "3.8" - name: CPython 3.9 runs-on: "3.9" - name: CPython 3.10 @@ -64,23 +67,37 @@ jobs: - name: CPython 3.11 runs-on: "3.11" - name: CPython 3.12 - runs-on: "3.12-dev" - - name: PyPy 3.9 - runs-on: "pypy-3.9" + runs-on: "3.12" + - name: CPython 3.13 + runs-on: "3.13" + - name: CPython 3.14 + runs-on: "3.14-dev" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python.runs-on }} cache: pip check-latest: true - - name: Install test dependencies + - name: Install dependencies run: | - python -m pip install -U pip wheel - python -m pip install -e '.[dev,test]' + python -m pip install -U pip + python -m pip install -e '.[dev,tests]' - name: Tests (GNU/Linux) if: matrix.os.emoji == '🐧' run: xvfb-run python -m pytest - name: Tests (macOS, Windows) if: matrix.os.emoji != '🐧' run: python -m pytest + + automerge: + name: Automerge + runs-on: ubuntu-latest + needs: [documentation, quality, tests] + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Automerge + run: gh pr merge --auto --rebase "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.gitignore b/.gitignore index d117f10a..205fb8a3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ .DS_Store *.orig *.jpg -*.png +*.mp4 +/*.png *.png.old *.pickle *.pyc @@ -13,11 +14,11 @@ build/ .cache/ dist/ +docs/__intersphinx_cache__/ docs_out/ *.egg-info/ .idea/ .pytest_cache/ -.vscode/ docs/output/ .mypy_cache/ __pycache__/ diff --git a/.readthedocs.yml b/.readthedocs.yml index 0a201cf2..c62360fd 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,5 +1,22 @@ -# http://read-the-docs.readthedocs.io/en/latest/yaml-config.html +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +sphinx: + configuration: docs/source/conf.py + fail_on_warning: true + +formats: + - htmlzip + - epub + - pdf -# Use that Python version to build the documentation python: - version: 3 + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..a7346f98 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,14 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. + // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp + // List of extensions which should be recommended for users of this workspace. + "recommendations": [ + "charliermarsh.ruff", + "ms-python.mypy-type-checker", + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.vscode-python-envs", + ], + // List of extensions recommended by VS Code that should not be recommended for users of this workspace. + "unwantedRecommendations": [] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..0d349238 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,28 @@ +{ + "python.analysis.typeCheckingMode": "off", // We'll use Mypy instead of the built-in Pyright + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "ruff.enable": true, + + "languageToolLinter.languageTool.ignoredWordsInWorkspace": [ + "bgra", + "ctypes", + "eownis", + "memoization", + "noop", + "numpy", + "oros", + "pylint", + "pypy", + "python-mss", + "pythonista", + "sdist", + "sourcery", + "tk", + "tkinter", + "xlib", + "xrandr", + "xserver", + "zlib" + ], +} diff --git a/.well-known/funding-manifest-urls b/.well-known/funding-manifest-urls new file mode 100644 index 00000000..b59ae9a3 --- /dev/null +++ b/.well-known/funding-manifest-urls @@ -0,0 +1 @@ +https://www.tiger-222.fr/funding.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e18ea6..8ef9984a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,45 @@ # History -See Git checking messages for full history. - -## 9.0.2 (2023/xx/xx) +See Git commit messages for full history. + +## v10.2.0.dev0 +(2026-xx-xx) +- Introduce a new API, mss.MSS, that we can keep stable as we continue to improve MSS. The previous documented API is deprecated, but still available, in 10.2. Some parts of the existing API (such as certain type names and internal variables) may be removed in 11.0, but the most important parts will remain available for as long as can be reasonably supported. (#486, #494) +- Add Versioning chapter to docs (#496) +- Add `is_primary`, `name`, and `unique_id` keys to Monitor dicts for primary monitor detection, device names, and stable per-monitor identification (#153) +- Add `primary_monitor` property to MSS base class for easy access to the primary monitor (#153) +- Windows: add primary monitor detection using `GetMonitorInfoW` API (#153) +- Windows: add monitor device name and unique device interface name using `EnumDisplayDevicesW` API (#153) +- Linux: add primary monitor detection, monitor device name, unique device interface name, and output name using XRandR (#153) +- Windows: switch from `GetDIBits` to more memory efficient `CreateDIBSection` for `MSS.grab` implementation (#449) +- Windows: fix gdi32.GetDIBits() failed after a couple of minutes of recording (#268) +- Linux: check the server for Xrandr support version (#417) +- Linux: improve typing and error messages for X libraries (#418) +- Linux: introduce an XCB-powered backend stack with a factory in ``mss.linux`` while keeping the Xlib code as a fallback (#425) +- Linux: add the XShmGetImage backend with automatic XGetImage fallback and explicit status reporting (#431) +- Windows: improve error checking and messages for Win32 API calls (#448) +- Mac: fix memory leak (#450, #453) +- improve multithreading: allow multiple threads to use the same MSS object, allow multiple MSS objects to concurrently take screenshots, and document multithreading guarantees (#446, #452) +- Add full demos for different ways to use MSS (#444, #456, #465) +- :heart: contributors: @jholveck, @halldorfannar + +## v10.1.0 +(2025-08-16) +- Mac: up to 60% performances improvement by taking screenshots at nominal resolution (e.g. scaling is off by default). To enable back scaling, set `mss.darwin.IMAGE_OPTIONS = 0`. (#257) +- docs: use the [shibuya](https://shibuya.lepture.com) theme +- :heart: contributors: @brycedrennan + +## v10.0.0 +(2024-11-14) +- removed support for Python 3.8 +- added support for Python 3.14 +- Linux: fixed a threading issue in `.close()` when calling `XCloseDisplay()` (#251) +- Linux: minor optimization when checking for a X extension status (#251) +- :heart: contributors: @kianmeng, @shravanasati, @mgorny + +## v9.0.2 +(2024-09-01) +- added support for Python 3.13 - leveled up the packaging using `hatchling` - used `ruff` to lint the code base (#275) - MSS: minor optimization when using an output file format without date (#275) @@ -10,10 +47,12 @@ See Git checking messages for full history. - CI: automated release publishing on tag creation - :heart: contributors: @Andon-Li -## 9.0.1 (2023/04/20) +## v9.0.1 +(2023-04-20) - CLI: fixed entry point not taking into account arguments -## 9.0.0 (2023/04/18) +## v9.0.0 +(2023-04-18) - Linux: add failure handling to `XOpenDisplay()` call (fixes #246) - Mac: tiny improvement in monitors finding - Windows: refactored how internal handles are stored (fixes #198) @@ -24,27 +63,31 @@ See Git checking messages for full history. - tests: automatic rerun in case of failure (related to #251) - :heart: contributors: @mgorny, @CTPaHHuK-HEbA -## 8.0.3 (2023/04/15) +## v8.0.3 +(2023-04-15) - added support for Python 3.12 - MSS: added PEP 561 compatibility - MSS: include more files in the sdist package (#240) - Linux: restore the original X error handler in `.close()` (#241) - Linux: fixed `XRRCrtcInfo.width`, and `XRRCrtcInfo.height`, C types -- doc: use markdown for the README, and changelogs +- docs: use Markdown for the README, and changelogs - dev: renamed the `master` branch to `main` - dev: review the structure of the repository to fix/improve packaging issues (#243) - :heart: contributors: @mgorny, @relent95 -## 8.0.2 (2023/04/09) +## v8.0.2 +(2023-04-09) - fixed `SetuptoolsDeprecationWarning`: Installing 'XXX' as data is deprecated, please list it in packages - CLI: fixed arguments handling -## 8.0.1 (2023/04/09) +## v8.0.1 +(2023-04-09) - MSS: ensure `--with-cursor`, and `with_cursor` argument & attribute, are simple NOOP on platforms not supporting the feature -- CLI: do not raise a ScreenShotError when `-q`, or `--quiet`, is used but return ` +- CLI: do not raise a `ScreenShotError` when `-q`, or `--quiet`, is used but return ` - tests: fixed `test_entry_point()` with multiple monitors having the same resolution -## 8.0.0 (2023/04/09) +## v8.0.0 +(2023-04-09) - removed support for Python 3.6 - removed support for Python 3.7 - MSS: fixed PEP 484 prohibits implicit Optional @@ -54,38 +97,43 @@ See Git checking messages for full history. - Linux: removed side effects when leaving the context manager, resources are all freed (fixes #210) - Linux: added mouse support (related to #55) - CLI: added `--with-cursor` argument -- tests: added PyPy 3.9, removed tox, and improved GNU/Linux coverage +- tests: added PyPy 3.9, removed `tox`, and improved GNU/Linux coverage - :heart: contributors: @zorvios -## 7.0.1 (2022/10/27) +## v7.0.1 +(2022-10-27) - fixed the wheel package -## 7.0.0 (2022/10/27) +## v7.0.0 +(2022-10-27) - added support for Python 3.11 - added support for Python 3.10 - removed support for Python 3.5 - MSS: modernized the code base (types, `f-string`, ran `isort` & `black`) (closes #101) - MSS: fixed several Sourcery issues - MSS: fixed typos here, and there -- doc: fixed an error when building the documentation +- docs: fixed an error when building the documentation -## 6.1.0 (2020/10/31) -- MSS: reworked how C functions are initialised +## v6.1.0 +(2020-10-31) +- MSS: reworked how C functions are initialized - Mac: reduce the number of function calls - Mac: support macOS Big Sur (fixes #178) - tests: expand Python versions to 3.9 and 3.10 -- tests: fixed macOS intepreter not found on Travis-CI +- tests: fixed macOS interpreter not found on Travis-CI - tests: fixed `test_entry_point()` when there are several monitors -## 6.0.0 (2020/06/30) +## v6.0.0 +(2020-06-30) - removed usage of deprecated `license_file` option for `license_files` - fixed flake8 usage in pre-commit -- the module is now available on conda (closes #170) +- the module is now available on Conda (closes #170) - MSS: the implementation is now thread-safe on all OSes (fixes #169) - Linux: better handling of the Xrandr extension (fixes #168) - tests: fixed a random bug on `test_grab_with_tuple_percents()` (fixes #142) -## 5.1.0 (2020/04/30) +## v5.1.0 +(2020-04-30) - produce wheels for Python 3 only - MSS: renamed again `MSSMixin` to `MSSBase`, now derived from `abc.ABCMeta` - tools: force write of file when saving a PNG file @@ -93,109 +141,125 @@ See Git checking messages for full history. - Windows: fixed multi-thread safety (fixes #150) - :heart: contributors: @narumishi -## 5.0.0 (2019/12/31) +## v5.0.0 +(2019-12-31) - removed support for Python 2.7 - MSS: improve type annotations and add CI check - MSS: use `__slots__` for better performances - MSS: better handle resources to prevent leaks - MSS: improve monitors finding - Windows: use our own instances of `GDI32` and `User32` DLLs -- doc: add `project_urls` to `setup.cfg` -- doc: add an example using the multiprocessing module (closes #82) +- docs: add `project_urls` to `setup.cfg` +- docs: add an example using the multiprocessing module (closes #82) - tests: added regression tests for #128 and #135 - tests: move tests files into the package - :heart: contributors: @hugovk, @foone, @SergeyKalutsky -## 4.0.2 (2019/02/23) +## v4.0.2 +(2019-02-23) - Windows: ignore missing `SetProcessDPIAware()` on Window XP (fixes #109) - :heart: contributors: @foone -## 4.0.1 (2019/01/26) +## v4.0.1 +(2019-01-26) - Linux: fixed several Xlib functions signature (fixes #92) - Linux: improve monitors finding by a factor of 44 -## 4.0.0 (2019/01/11) +## v4.0.0 +(2019-01-11) - MSS: remove use of `setup.py` for `setup.cfg` - MSS: renamed `MSSBase` to `MSSMixin` in `base.py` - MSS: refactor ctypes `argtype`, `restype` and `errcheck` setup (fixes #84) - Linux: ensure resources are freed in `grab()` - Windows: avoid unnecessary class attributes - MSS: ensure calls without context manager will not leak resources or document them (fixes #72 and #85) -- MSS: fixed Flake8 C408: Unnecessary dict call- rewrite as a literal, in `exceptions.py` +- MSS: fixed Flake8 C408: Unnecessary dict call - rewrite as a literal, in `exceptions.py` - MSS: fixed Flake8 I100: Import statements are in the wrong order - MSS: fixed Flake8 I201: Missing newline before sections or imports - MSS: fixed PyLint bad-super-call: Bad first argument 'Exception' given to `super()` -- tests: use tox, enable PyPy and PyPy3, add macOS and Windows CI +- tests: use `tox`, enable PyPy and PyPy3, add macOS and Windows CI -## 3.3.2 (2018/11/20) +## v3.3.2 +(2018-11-20) - MSS: do monitor detection in MSS constructor (fixes #79) - MSS: specify compliant Python versions for pip install - tests: enable Python 3.7 - tests: fixed `test_entry_point()` with multiple monitors - :heart: contributors: @hugovk, @andreasbuhr -## 3.3.1 (2018/09/22) +## v3.3.1 +(2018-09-22) - Linux: fixed a memory leak introduced with 7e8ae5703f0669f40532c2be917df4328bc3985e (fixes #72) -- doc: add the download statistics badge +- docs: add the download statistics badge -## 3.3.0 (2018/09/04) +## v3.3.0 +(2018-09-04) - Linux: add an error handler for the XServer to prevent interpreter crash (fixes #61) - MSS: fixed a `ResourceWarning`: unclosed file in `setup.py` - tests: fixed a `ResourceWarning`: unclosed file -- doc: fixed a typo in `Screenshot.pixel()` method (thanks to @mchlnix) +- docs: fixed a typo in `Screenshot.pixel()` method (thanks to @mchlnix) - big code clean-up using `black` -## 3.2.1 (2018/05/21) +## v3.2.1 +(2018-05-21) - Windows: enable Hi-DPI awareness - :heart: contributors: @ryanfox -## 3.2.0 (2018/03/22) +## v3.2.0 +(2018-03-22) - removed support for Python 3.4 - MSS: add the `Screenshot.bgra` attribute - MSS: speed-up grabbing on the 3 platforms - tools: add PNG compression level control to `to_png()` - tests: add `leaks.py` and `benchmarks.py` for manual testing -- doc: add an example about capturing part of the monitor 2 -- doc: add an example about computing BGRA values to RGB +- docs: add an example about capturing part of the monitor 2 +- docs: add an example about computing BGRA values to RGB -## 3.1.2 (2018/01/05) +## v3.1.2 +(2018-01-05) - removed support for Python 3.3 - MSS: possibility to get the whole PNG raw bytes -- Windows: capture all visible windows -- doc: improvements and fixes (fixes #37) +- Windows: capture all visible window +- docs: improvements and fixes (fixes #37) - CI: build the documentation -## 3.1.1 (2017/11/27) +## v3.1.1 +(2017-11-27) - MSS: add the `mss` entry point -## 3.1.0 (2017/11/16) +## v3.1.0 +(2017-11-16) - MSS: add more way of customization to the output argument of `save()` -- MSS: possibility to use custom class to handle screen shot data +- MSS: possibility to use custom class to handle screenshot data - Mac: properly support all display scaling and resolutions (fixes #14, #19, #21, #23) - Mac: fixed memory leaks (fixes #24) - Linux: handle bad display value - Windows: take into account zoom factor for high-DPI displays (fixes #20) -- doc: several fixes (fixes #22) +- docs: several fixes (fixes #22) - tests: a lot of tests added for better coverage - add the 'Say Thanks' button - :heart: contributors: @karanlyons -## 3.0.1 (2017/07/06) +## v3.0.1 +(2017-07-06) - fixed examples links -## 3.0.0 (2017/07/06) +## v3.0.0 +(2017-07-06) - big refactor, introducing the `ScreenShot` class - MSS: add Numpy array interface support to the `Screenshot` class -- doc: add OpenCV/Numpy, PIL pixels, FPS +- docs: add OpenCV/Numpy, PIL pixels, FPS -## 2.0.22 2017/04/29 +## v2.0.22 +(2017-04-29) - MSS: better use of exception mechanism -- Linux: use of hasattr to prevent Exception on early exit +- Linux: use of `hasattr()` to prevent Exception on early exit - Mac: take into account extra black pixels added when screen with is not divisible by 16 (fixes #14) -- doc: add an example to capture only a part of the screen +- docs: add an example to capture only a part of the screen - :heart: contributors: David Becker, @redodo -## 2.0.18 2016/12/03 +## v2.0.18 +(2016-12-03) - change license to MIT - MSS: add type hints - MSS: remove unused code (reported by `Vulture`) @@ -204,11 +268,12 @@ See Git checking messages for full history. - Linux: skip unused monitors - Linux: use `errcheck` instead of deprecated `restype` with callable (fixes #11) - Linux: fixed security issue (reported by Bandit) -- doc: add documentation (fixes #10) +- docs: add documentation (fixes #10) - tests: add tests and use Travis CI (fixes #9) - :heart: contributors: @cycomanic -## 2.0.0 (2016/06/04) +## v2.0.0 +(2016-06-04) - add issue and pull request templates - split the module into several files - MSS: a lot of code refactor and optimizations @@ -218,17 +283,20 @@ See Git checking messages for full history. - Linux: prevent segfault when `DISPLAY` is set but no X server started - Linux: prevent segfault when Xrandr is not loaded - Linux: `get_pixels()` insanely fast, use of MSS library (C code) -- Windows: screen shot not correct on Windows 8 (fixes #6) +- Windows: screenshot not correct on Windows 8 (fixes #6) -## 1.0.2 (2016/04/22) -- MSS: fixed non existent alias +## v1.0.2 +(2016-04-22) +- MSS: fixed non-existent alias -## 1.0.1 (2016/04/22) +## v1.0.1 +(2016-04-22) - MSS: `libpng` warning (ignoring bad filter type) (fixes #7) -## 1.0.0 (2015/04/16) +## v1.0.0 +(2015-04-16) - Python 2.6 to 3.5 ready -- MSS: code purgation and review, no more debug information +- MSS: code clean-up and review, no more debug information - MSS: add a shortcut to take automatically use the proper `MSS` class (fixes #5) - MSS: few optimizations into `save_img()` - Darwin: remove rotation from information returned by `enum_display_monitors()` @@ -238,30 +306,35 @@ See Git checking messages for full history. - Windows: huge optimization of `get_pixels()` - CLI: delete `--debug` argument -## 0.1.1 (2015/04/10) +## v0.1.1 +(2015-04-10) - MSS: little code review - Linux: fixed monitor count - tests: remove `test-linux` binary -- doc: add `doc/TESTING` -- doc: remove Bonus section from README +- docs: add `doc/TESTING` +- docs: remove Bonus section from README -## 0.1.0 (2015/04/10) +## v0.1.0 +(2015-04-10) - MSS: fixed code with `YAPF` tool - Linux: fully functional using Xrandr library -- Linux: code purgation (no more XML files to parse) -- doc: better tests and examples +- Linux: code clean-up (no more XML files to parse) +- docs: better tests and examples -## 0.0.8 (2015/02/04) -- MSS: filename's dir is not used when saving (fixes #3) -- MSS: fixed flake8 error: E713 test for membership should be 'not in' +## v0.0.8 +(2015-02-04) +- MSS: filename's directory is not used when saving (fixes #3) +- MSS: fixed flake8 error: E713 test for membership should be 'not in' - MSS: raise an exception for unimplemented methods - Windows: robustness to `MSSWindows.get_pixels` (fixes #4) - :heart: contributors: @sergey-vin, @thehesiod -## 0.0.7 (2014/03/20) +## v0.0.7 +(2014-03-20) - MSS: fixed path where screenshots are saved -## 0.0.6 (2014/03/19) +## v0.0.6 +(2014-03-19) - Python 3.4 ready - PEP8 compliant - MSS: review module structure to fit the "Code Like a Pythonista: Idiomatic Python" @@ -273,23 +346,28 @@ See Git checking messages for full history. - CLI: possibility to append `--debug` to the command line - :heart: contributors: @sametmax -## 0.0.5 (2013/11/01) +## v0.0.5 +(2013-11-01) - MSS: code simplified - Windows: few optimizations into `_arrange()` -## 0.0.4 (2013/10/31) -- Linux: use of memoization => huge time/operations gains +## v0.0.4 +(2013-10-31) +- Linux: use of memoization → huge time/operations gains -## 0.0.3 (2013/10/30) +## v0.0.3 +(2013-10-30) - MSS: removed PNG filters - MSS: removed `ext` argument, using only PNG - MSS: do not overwrite existing image files - MSS: few optimizations into `png()` - Linux: few optimizations into `get_pixels()` -## 0.0.2 (2013/10/21) +## v0.0.2 +(2013-10-21) - added support for python 3 on Windows and GNU/Linux - :heart: contributors: Oros, Eownis -## 0.0.1 (2013/07/01) +## v0.0.1 +(2013-07-01) - first release diff --git a/CHANGES.md b/CHANGES.md index ea3376f3..9fb44b85 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,82 @@ # Technical Changes +## 10.2.0 (2026-xx-xx) + +### base.py +- (This affected almost every file in some respect, but base.py was the most affected.) Introduced a new API, mss.MSS. This class can be used instead of the previous various MSSBase subclasses, so that the user can work with a consistent class regardless of implementation details. The methods implemented by MSSBase subclasses were renamed, and moved to MSSImplementation subclasses. The mss.MSS class now holds an MSSImplementation instance as an instance variable. (#486, #494) +- Added `primary_monitor` property to return the primary monitor (or first monitor as fallback). + +### models.py +- Changed `Monitor` type from `dict[str, int]` to `dict[str, Any]` to support new `is_primary` (bool, optional), `name` (str, optional), and `unique_id` (str, optional) fields. +- Added TODO comment for future Monitor class implementation (#470). + +### windows.py +- Added `MONITORINFOEXW` structure for extended monitor information. +- Added `DISPLAY_DEVICEW` structure for device information. +- Added constants: `CCHDEVICENAME`, `MONITORINFOF_PRIMARY`, `EDD_GET_DEVICE_INTERFACE_NAME`. +- Added `GetMonitorInfoW` to `CFUNCTIONS` for querying monitor properties. +- Added `EnumDisplayDevicesW` to `CFUNCTIONS` for querying device details. +- Modified `_monitors_impl()` callback to extract primary monitor flag, device names, and device interface name (unique_id) using Win32 APIs; `unique_id` uses `EDD_GET_DEVICE_INTERFACE_NAME` when available. + +### linux/base.py +- Reworked `_monitors_impl()` to prefer XRandR 1.5+ `GetMonitors` when available, falling back to enumerating active CRTCs. +- Added monitor identification fields from RandR + EDID where available: `is_primary`, `output`, `name`, and `unique_id`. +- Added EDID lookup via RandR `EDID`/`EdidData` output property and parsing via `mss.tools.parse_edid()`. + +### linux/xcb.py +- Added `intern_atom()` helper with per-connection caching and support for predefined atoms. +- Added `XCB_NONE` constant (`Atom(0)`). +- Added additional XRandR request wrappers used for monitor identification (`GetMonitors`, `GetOutputInfo`, `GetOutputPrimary`, `GetOutputProperty`). + +### linux/xcbhelpers.py +- Added `InternAtomReply` structure and typed binding for `xcb_intern_atom`. +- Added `__eq__()`/`__hash__()` to `XID` for value-based comparisons. + +### xcbproto/gen_xcb_to_py.py +- Extended the generator to include additional XRandR requests used by the XCB backends (`GetOutputInfo`, `GetOutputPrimary`, `GetOutputProperty`, `GetMonitors`). +- Updated typedef generation to emit value-based `__eq__()`/`__hash__()` implementations. +- Refactored code generation helpers and formatting (use `textwrap.indent`/`dedent`). + +### tools.py +- Added `parse_edid()` helper for extracting identifying fields (legacy model id, serial number, manufacture/model year, and display name) from EDID blocks. + +### linux/xshmgetimage.py +- Fixed XID type handling for `drawable`/`visual` (avoid mixing raw `.value` with typed IDs). + +## 10.1.1 (2025-xx-xx) + +### linux/__init__.py +- Added an ``mss()`` factory to select between the different GNU/Linux backends. + +### linux/xlib.py +- Moved the legacy Xlib backend into the ``mss.linux.xlib`` module to be used as a fallback implementation. + +### linux/xgetimage.py +- Added an XCB-based backend that mirrors XGetImage semantics. + +### linux/xshmgetimage.py +- Added an XCB backend powered by XShmGetImage with ``shm_status`` and ``shm_fallback_reason`` attributes for diagnostics. + +## 10.1.0 (2025-08-16) + +### darwin.py +- Added `IMAGE_OPTIONS` +- Added `kCGWindowImageBoundsIgnoreFraming` +- Added `kCGWindowImageNominalResolution` +- Added `kCGWindowImageShouldBeOpaque` + +## 10.0.0 (2024-11-14) + +### 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 @@ -183,7 +260,7 @@ ## 3.0.0 (2017-07-06) ### base.py -- Added the `ScreenShot` class containing data for a given screen shot (support the Numpy array interface [`ScreenShot.__array_interface__`]) +- Added the `ScreenShot` class containing data for a given screenshot (support the Numpy array interface [`ScreenShot.__array_interface__`]) - Added `shot()` method to `MSSBase`. It takes the same arguments as the `save()` method. - Renamed `get_pixels` to `grab`. It now returns a `ScreenShot` object. - Moved `to_png` method to `tools.py`. It is now a simple function. @@ -195,7 +272,7 @@ - Removed `bgra_to_rgb()` method. Use `ScreenShot.rgb` property instead. ### darwin.py -- Removed `_crop_width()` method. Screen shots are now using the width set by the OS (rounded to 16). +- Removed `_crop_width()` method. Screenshots are now using the width set by the OS (rounded to 16). ### exception.py - Renamed `ScreenshotError` class to `ScreenShotError` diff --git a/LICENSE.txt b/LICENSE.txt index bdcbc505..95abf4ec 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,5 +1,5 @@ MIT License -Copyright (c) 2013-2024, Mickaël 'Tiger-222' Schoentgen +Copyright (c) 2013-2026, Mickaël 'Tiger-222' Schoentgen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index eb0bfce0..99cb10b8 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,35 @@ [![PyPI version](https://badge.fury.io/py/mss.svg)](https://badge.fury.io/py/mss) [![Anaconda version](https://anaconda.org/conda-forge/python-mss/badges/version.svg)](https://anaconda.org/conda-forge/python-mss) -[![Tests workflow](https://github.com/BoboTiG/python-mss/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/BoboTiG/python-mss/actions/workflows/tests.yml) +[![Tests](https://github.com/BoboTiG/python-mss/actions/workflows/tests.yml/badge.svg)](https://github.com/BoboTiG/python-mss/actions/workflows/tests.yml) [![Downloads](https://static.pepy.tech/personalized-badge/mss?period=total&units=international_system&left_color=black&right_color=orange&left_text=Downloads)](https://pepy.tech/project/mss) +> [!TIP] +> Become **my boss** to help me work on this awesome software, and make the world better: +> +> [![Patreon](https://img.shields.io/badge/Patreon-F96854?style=for-the-badge&logo=patreon&logoColor=white)](https://www.patreon.com/mschoentgen) + ```python -from mss import mss +from mss import MSS -# The simplest use, save a screen shot of the 1st monitor -with mss() as sct: +# The simplest use, save a screenshot of the 1st monitor +with MSS() as sct: sct.shot() ``` -An ultra fast cross-platform multiple screenshots module in pure python using ctypes. +An ultra-fast cross-platform multiple screenshots module in pure python using ctypes. -- **Python 3.8+**, PEP8 compliant, no dependency, thread-safe; -- very basic, it will grab one screen shot by monitor or a screen shot of all monitors and save it to a PNG file; +- **Python 3.9+**, PEP8 compliant, no dependency, thread-safe; +- very basic, it will grab one screenshot by monitor or a screenshot of all monitors and save it to a PNG file; - but you can use PIL and benefit from all its formats (or add yours directly); - integrate well with Numpy and OpenCV; -- it could be easily embedded into games and other software which require fast and platform optimized methods to grab screen shots (like AI, Computer Vision); +- it could be easily embedded into games and other software which require fast and platform optimized methods to grab screenshots (like AI, Computer Vision); - get the [source code on GitHub](https://github.com/BoboTiG/python-mss); - learn with a [bunch of examples](https://python-mss.readthedocs.io/examples.html); - you can [report a bug](https://github.com/BoboTiG/python-mss/issues); -- need some help? Use the tag *python-mss* on [StackOverflow](https://stackoverflow.com/questions/tagged/python-mss); +- need some help? Use the tag *python-mss* on [Stack Overflow](https://stackoverflow.com/questions/tagged/python-mss); - and there is a [complete, and beautiful, documentation](https://python-mss.readthedocs.io) :) -- **MSS** stands for Multiple Screen Shots; +- **MSS** stands for Multiple ScreenShots; ## Installation @@ -36,8 +41,10 @@ You can install it with pip: python -m pip install -U --user mss ``` -Or you can install it with conda: +Or you can install it with Conda: ```shell conda install -c conda-forge python-mss ``` + +In case of scaling and high DPI issues for external monitors: some packages (e.g. `mouseinfo` / `pyautogui` / `pyscreeze`) incorrectly call `SetProcessDpiAware()` during import process. To prevent that, import `mss` first. diff --git a/check.ps1 b/check.ps1 new file mode 100644 index 00000000..0e8adf11 --- /dev/null +++ b/check.ps1 @@ -0,0 +1,10 @@ +# +# Small script to ensure quality checks pass before submitting a commit/PR. +# +$ErrorActionPreference = "Stop" + +python -m ruff format docs src +python -m ruff check --fix --unsafe-fixes docs src + +# "--platform win32" to not fail on ctypes.windll (it does not affect the overall check on other OSes) +python -m mypy --platform win32 src docs/source/examples diff --git a/check.sh b/check.sh index 7bb90ae2..d07b3576 100755 --- a/check.sh +++ b/check.sh @@ -4,8 +4,8 @@ # set -eu -python -m ruff --fix docs src python -m ruff format docs src +python -m ruff check --fix --unsafe-fixes docs src # "--platform win32" to not fail on ctypes.windll (it does not affect the overall check on other OSes) python -m mypy --platform win32 src docs/source/examples diff --git a/demos/cat-detector.py b/demos/cat-detector.py new file mode 100755 index 00000000..596dd997 --- /dev/null +++ b/demos/cat-detector.py @@ -0,0 +1,383 @@ +#! /usr/bin/env python3 + +# This demo shows how to use MSS for artificial intelligence. For this demo, we'll be using a simple object detection +# task: see if there's a cat on your monitor. I mean, displayed on the monitor, not sitting on your laptop. +# +# This demo is not meant to be an introduction to AI or computer vision. We assume you have an understanding of the +# basics of AI, and of PyTorch. +# +# Object Detection +# ================ +# +# An object detector is a different beast than an object classifier. Object classifiers are a common introduction to +# computer vision. These will look at a picture that has a single foreground object, front and center, and try to +# identify what type of object this is: a cat, person, bicycle, etc. +# +# An object detector looks at an image and identifies _multiple objects_ within it. Instead of assigning a single +# label to the whole image, saying "this is a picture of a cat", it might say "there is a cat here, and a bicycle over +# there," and provide some basic information about each one. This is, for instance, what a self-driving car uses to +# identify what it's seeing on its cameras. +# +# For this demo, we want to tell if a cat is anywhere on the screen, not if the whole screen is a picture of a cat. +# That means that we want to use a detector, not a classifier. +# +# The detector will find any number of objects. For each object it detects, a typical detector produces three pieces +# of information: +# +# - A *label*, which identifies _what kind of object_ the detector believes it has found. Labels are represented +# internally as integers that map to a fixed list of categories the model was trained on (for example, "cat," +# "bicycle," or "person"). +# +# - A *position*, usually given as a bounding box. A bounding box describes _where_ the object appears in the image, +# using a small set of numbers that define a rectangle around it. +# +# - A *score*, which indicates how confident the model is in that detection. Higher scores mean the model is more +# confident; lower scores mean it is less confident. The score is a relative confidence signal, not a calibrated +# probability, and it should not be interpreted as a percentage or compared across different models. +# +# Most modern object detectors follow this same basic pattern, even if their internal architectures differ. In the +# Torchvision model used in this demo, these results are returned as parallel one-dimensional tensors: one tensor of +# labels, one tensor of bounding boxes, and one tensor of scores. Each index across these tensors refers to the same +# detected object. +# +# The Model We're Using +# ===================== +# +# In this demo, we use a pre-trained object-detection model provided by PyTorch's Torchvision library: +# `fasterrcnn_resnet50_fpn_v2`, with weights `FasterRCNN_ResNet50_FPN_V2_Weights.COCO_V1`. +# +# This name is long, but each part reflects a piece of a larger system built up over many years of research and +# engineering. +# +# *Faster R-CNN* is the overall object-detection architecture. Introduced in 2015, it builds on earlier R-CNN +# variants and established the now-common two-stage approach to detection: first proposing regions that might contain +# objects, then classifying and refining those regions. This basic structure is still widely used today. +# +# *ResNet-50* refers to the convolutional neural network used as the _backbone_. ResNet itself was originally +# developed for image classification, but its feature-extraction layers proved broadly useful and are now reused in +# many vision systems. In this model, ResNet-50 converts raw pixels into _features_ - numerical representations that +# capture visual patterns such as edges, textures, shapes, and object parts - while the original classification layers +# are replaced by the detection-specific components of Faster R-CNN. +# +# *FPN*, or Feature Pyramid Network, is a later addition that addresses one of the main challenges in object +# detection: scale. It combines high-level, semantically rich features (good at recognizing _what_ is present) with +# lower-level, higher-resolution features (better at preserving _where_ things are). By layering these ideas on top +# of the backbone, the model can detect both large and small objects more reliably. +# +# The *v2* suffix indicates a newer Torchvision implementation that incorporates refinements from more recent research +# and practice. In particular, it follows a standardized training and configuration setup described in the 2021 paper +# "Benchmarking Detection Transfer Learning with Vision Transformers". Despite the paper's title, this model does +# *not* use Transformers; it uses a ResNet-50 backbone, but benefits from the same modernized training approach. +# +# Finally, *COCO_V1* indicates that the model was trained on the COCO dataset, a widely used community benchmark for +# object detection. COCO contains hundreds of thousands of labeled images covering 80 common object categories (such +# as people, animals, and vehicles), along with a small number of additional placeholder categories that appear as +# "N/A" in the model metadata. +# +# Performance +# =========== +# +# This demo can run the model on either the CPU or a GPU. The single biggest factor affecting performance is which +# one you use. Modern neural networks are designed around large amounts of parallel computation, which GPUs handle +# much more efficiently than CPUs. In practice, that means the same model runs dramatically faster on a GPU than on +# the CPU, even though the underlying math is identical. PyTorch's strongest and most mature GPU support today is +# through Nvidia's CUDA platform, so that is the only GPU supported by this demo. +# +# Screen size has little effect on performance. The model starts by scaling the captured image to a consistent size +# (fitting it within 1333x800 px), so the slow part - running the neural network - takes roughly the same amount of +# time regardless of the original screen resolution. +# +# With a CUDA-capable GPU, this demo's main loop typically runs in around 100 ms per frame (about 10 fps). When run +# on the CPU, the same work takes roughly 5000 ms per frame (about 0.2 fps). +# +# Cached Data +# =========== +# +# The first time you run this demo, Torchvision will download a 167 MByte DNN. This is cached in +# ~/.cache/torch/hub/checkpoints on Unix. If you want to know where the cache is stored on other platforms, it will +# be displayed while downloading the DNN. + +from __future__ import annotations + +import itertools +import time + +# You'll need to install PyTorch and TorchVision, and the best way to do that can vary depending on your system. +# Often, "pip install torch torchvision" will be sufficient, but you can get specific instructions at +# . +import torch +import torchvision.models.detection +import torchvision.transforms.v2 + +# You'll also need to install MSS and Pillow, such as with "pip install mss pillow". +from PIL import Image + +import mss + +# The model will identify objects even if they only vaguely look like something. It also tell us a score of how +# certain it is, on a scale from 0 (not a cat) to 1 (very confidently a cat). To prevent false positives, we set a +# threshold and ignore any results below it. The score doesn't have any real external meaning: to pick the cutoff, +# you just try different images, look at the scores, and get a sense of what seems about right. +SCORE_THRESH = 0.60 + +# If an image is too small, then it's got a pretty decent chance of being a false positive: it's hard to tell if a +# Discord or Slack reaction icon is a cat or something different. We ignore any results that are too small to be +# reliable. Here, this cutoff is 0.1% of the whole monitor (about 1.5 cm square on a 27" monitor, the diameter of a +# AA battery). Like the score threshold, this is just something you try and see what the model is able to +# recognize reliably. +MIN_AREA_FRAC = 0.001 + + +# This function is here for illustrative purposes: the demo doesn't currently call it, but there's a commented-out +# line in the main loop that shows how you might use it. +def screenshot_to_tensor(sct_img: mss.ScreenShot, device: str | torch.device) -> torch.Tensor: + """Convert an MSS ScreenShot to a CHW PyTorch tensor.""" + + # Get a 1d tensor of BGRA values. PyTorch will issue a warning at this step: the ScreenShot's bgra object is + # read-only, but PyTorch doesn't support read-only tensors. However, this is harmless in our case: we'll end up + # copying the data anyway. + img = torch.frombuffer(sct_img.bgra, dtype=torch.uint8) + # Bring everything to the desired device. This is still just a linear buffer of BGRA bytes. + img = img.to(device) + # The next two steps will all just create views of the original tensor, without copying the data. + img = img.view(sct_img.height, sct_img.width, 4) # Interpret as BGRA HWC + img = img.permute(2, 0, 1) # Permute the axes: BGRA CHW + # This final step will create a copy. Copying the data is required to reorder the channels. This also has the + # advantage of also making the tensor contiguous, for more efficient access. + img = img[[2, 1, 0], ...] # Reorder the channels: RGB CHW + return img + + +def top_unique_labels(labels: torch.Tensor, scores: torch.Tensor) -> torch.Tensor: + """Return the unique labels, ordered by descending score. + + If you have a person (0.67), dog (0.98), tv (0.88), dog (0.71), + you'll get back the labels for dog, tv, person, in that order. + """ + + # Find the set of unique labels. + # `uniq` contains each distinct label once. + # `inv` maps each original label to its index in `uniq`. + # + # Example: + # labels = [person, dog, tv, dog] + # uniq = [person, dog, tv] + # inv = [0, 1, 2, 1] + uniq, inv = torch.unique(labels, return_inverse=True) + + # Create a tensor to hold the maximum score seen for each unique label. We initialize to -inf so any real score + # will replace it. + max_per = torch.full( + (uniq.numel(),), + -torch.inf, + device=scores.device, + dtype=scores.dtype, + ) + + # For each element in `scores`, reduce it into `max_per` using `inv` as an index map, taking the maximum score per + # label. + # + # After this, max_per[i] is the highest score associated with uniq[i]. + max_per.scatter_reduce_(0, inv, scores, reduce="amax") + + # Sort the unique labels by their maximum score, highest first. + order = torch.argsort(max_per, descending=True) + + # Return the unique labels in score-ranked order. + return uniq[order] + + +# We run the entire program in inference mode. This is telling PyTorch to not bother tracking data that's only useful +# for training a neural net. +@torch.inference_mode() +def main() -> None: + # Prefer CUDA if available. PyTorch's CUDA backend is the most mature and consistently supported option, and can + # be tens of times faster than running the same model on the CPU. + # + # Other GPU backends (such as Apple's MPS, AMD ROCm, or Intel XPU) exist, but support and configuration vary + # widely across systems. Since this demo hasn't been tested on those platforms, it conservatively falls back to + # the CPU when CUDA is not available. + device = "cuda" if torch.cuda.is_available() else "cpu" + + # Neural networks, often just called *models*, have two aspects to them: the *architecture*, and the *weights*. + # The architecture is the layout of the neural network: what the different units are, how they're connected, and + # so forth. The weights are the results of training that neural network; they're numbers saying how much the + # units in the network influence each other. + # + # The same architecture can be trained on different data sets for different purposes. Different companies might + # use the exact same object detector architecture for different purposes: a company making a photo editing app + # might train the model to recognize faces, smiles, or closed eyes for auto-enhancement, while a wildlife research + # group could train the same architecture to identify animals in wilderness camera photos. + # + # The weights are specific to the architecture: you can't plug weights from a training run with the ResNet50 + # architecture into a Visual Transformers architecture. + # + # As described in the comments at the top of the file, we're using the fasterrcnn_resnet50_fpn_v2 architecture, + # and the weights obtained by training it with the COCO dataset. Plugging those weights into the architecture + # produces our model. + weights = torchvision.models.detection.FasterRCNN_ResNet50_FPN_V2_Weights.COCO_V1 + model = torchvision.models.detection.fasterrcnn_resnet50_fpn_v2(weights=weights) + # Move the model to the GPU, if we've selected that, and put it in evaluation mode (as opposed to training mode). + # Training mode often uses features meant to make the training more robust, such as randomly ignoring some + # connections to make sure the model learns some redundancy. Evaluation mode puts it in a mode to perform the + # best it can. + model = model.to(device).eval() + + # When you train a model, you almost always want to pre-process your input data. It's important that when you use + # that model later, you do the same kind of pre-processing. Otherwise, it'd be like learning a language from + # slow, carefully-enunciated speech, and then getting dropped right into conversations on a subway. + # + # For the model we're using, the preprocessing is simply to standardize the representation: it will convert PIL + # images to a tensor representation, and convert all images to floating-point 0.0-1.0 instead of integer 0-255. + # Some other models do more preprocessing. + # + # Fortunately, for its pretrained models, Torchvision gives us an easy way to get the correct preprocessing + # function. + preprocess = weights.transforms() + + # The labels ("what type of object is this") that the model gives us are just integers; for this model, they're + # from 0 to 90. The English words describing them (like "cat") are in a list, stored in the weight's metadata. + model_labels = weights.meta["categories"] + cat_label = model_labels.index("cat") + + with mss.MSS() as sct: + monitor = sct.monitors[1] + + # Compute the minimum size, in square pixels, that we'll consider reliable. + img_area = monitor["width"] * monitor["height"] + min_box_area = MIN_AREA_FRAC * img_area + + # We start a new line of the log if the cat visibility status changes. That way, your terminal will show + # essentially a log of all the times when a cat appeared or vanished. + cat_has_been_visible = False + + # Track an exponential moving average of how long each frame takes, essentially an FPS counter. + frame_duration_avg = None + + # When was the last frame? + prev_frame_start = None + + # We run forever, or until the user interrupts us. + print("Looking for kitty cats! Press Ctrl-C to stop.") + for frame_number in itertools.count(): + # Do all the work to keep the frame timer. + frame_start = time.monotonic() + if prev_frame_start is not None: # Skip the first loop + frame_duration = frame_start - prev_frame_start + # Track frame timing with exponential moving average. Skip the first few frames while PyTorch + # optimizes its computations. + if frame_number < 5: + frame_duration_avg = frame_duration + else: + # Exponential moving average: weight recent frame 10%, historical average 90%. This means each + # frame's influence halves every ~7 frames. + assert frame_duration_avg is not None + frame_duration_avg = frame_duration_avg * 0.9 + frame_duration * 0.1 + prev_frame_start = frame_start + + # Grab the screenshot. + sct_img = sct.grab(monitor) + + # We transfer the image from MSS to PyTorch via a Pillow Image. Faster approaches exist (see + # screenshot_to_tensor), but PIL is more readable. The bulk of the time in this program is spent doing + # the AI work, so we just use the most convenient mechanism. + img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") + + # We explicitly convert it to a tensor here, even though Torchvision can also convert it in the preprocess + # step. This is so that we send it to the GPU before we do the preprocessing: PIL Images are always on + # the CPU, and doing the preprocessing on the GPU is much faster. + # + # Most image APIs, including MSS, use an array layout of [height, width, channels]. In MSS, the + # ScreenShot.bgra data follows this convention, even though it's exposed as a flat bytes object. + # + # In contrast, most AI frameworks expect images in [channels, height, width] order. The pil_to_tensor + # helper performs this rearrangement for us. + img_tensor = torchvision.transforms.v2.functional.pil_to_tensor(img).to(device) + + # An alternative to using PIL is shown in screenshot_to_tensor. In one test, this saves about 20 ms per + # frame if using a GPU, and about 200 ms if using a CPU. This would replace the "img=" and "img_tensor=" + # lines above. + # + #img_tensor = screenshot_to_tensor(sct_img, device) + + # Do the preprocessing stages that the trained model expects; see the comment where we define preprocess. + # The traditional name for inputs to a neural net is "x", because AI programmers aren't terribly + # imaginative. + x = preprocess(img_tensor) + # In most AI networks, the model expects to take a batch of inputs, and will return an batch of outputs. + # This is because it's _much_ more efficient to operate on batches of inputs than on individual inputs + # when you're doing matrix math. For instance, banks will use batches of transactions in AIs to flag + # transactions for review as potentially fraudulent. Because of that design, we need to provide the model + # our input as a batch of one image, rather than a single image by itself. That's what the unsqueeze + # does: it adds a new dimension of length 1 to the beginning of the input. Also, the output will be in a + # batch, so we just take the first element, hence the [0]. + pred = model(x.unsqueeze(0))[0] + + # The value of pred is a dict, giving us the labels, scores, and bounding boxes. See the comments at the + # top of the file for more information. + labels = pred["labels"] + scores = pred["scores"] + boxes = pred["boxes"] + + # We only want to allow detections that are large enough to be reliable; see the comments on MIN_AREA_FRAC + # for more information. Here, we compute the areas of all the boxes we got, using operations that work on + # all the detected objects in parallel. + areas = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) + + # Find the score of the highest-scoring cat that's large enough, even if it's not high enough to register + # as sufficiently certain for our program. We always log that, as the "cat score". + cat_mask = (labels == cat_label) & (areas >= min_box_area) + cat_score = scores[cat_mask].max().item() if cat_mask.any() else 0.0 + + # Is there a cat on the screen? + cat_in_frame = cat_score >= SCORE_THRESH + # Did a cat just appear or disappear? We create a new log line when this happens, so the user gets a log + # of cat appearances and disappearances. + cat_status_changed = cat_in_frame != cat_has_been_visible + if cat_status_changed: + cat_has_been_visible = cat_in_frame + + if not cat_in_frame: + # Find all objects that score sufficiently well. We're going to log them if there's no cat to talk + # about. + mask = (scores >= SCORE_THRESH) & (areas >= min_box_area) + if mask.any(): + show_labels = top_unique_labels(labels[mask], scores[mask]) + else: + show_labels = torch.empty((0,), dtype=labels.dtype) + + # Give the user our results. + status_line_time = time.strftime("%H:%M:%S", time.localtime()) + if cat_in_frame: + status_line_msg = "Meow! Hello kitty-cat!" + else: + status_line_msg = "no cats" + # If there isn't a cat, but there are other objects, list them. + if show_labels.shape[0] != 0: + label_words = [model_labels[i] for i in show_labels.cpu()] + # Filter out anything marked as "N/A": these are non-objects (like "sky"), and the training for + # this model doesn't really cover them. + label_words = [w for w in label_words if w != "N/A"] + # Build these into a comma-separated list. Make sure the whole string is at most 31 characters, + # the width we provide for it in the message. + status_line_msg += f":{','.join(label_words)}" + if len(status_line_msg) > 31: + status_line_msg = status_line_msg[:28] + "..." + # The frame_duration_avg will be None in the first iteration, since there isn't yet a full iteration to + # measure. + duration_avg_str = f"{frame_duration_avg * 1000:5.0f}" if frame_duration_avg is not None else "-----" + + # Build the whole status line. It's a constant width, so that when we overwrite it each frame, the new + # status line will completely overwrite the previous one. + status_line = ( + f"{status_line_time} {frame_number:4d} " + f"{duration_avg_str} ms/frame " + f"| {status_line_msg:31s} (cat score={cat_score:.2f})" + ) + # If a cat just appeared or disappeared, start a new line after this status line. This lets the user see + # a history of all the cat status changes. + print(f"\r{status_line}", end="\n" if cat_status_changed else "") + + +if __name__ == "__main__": + main() diff --git a/demos/common/__init__.py b/demos/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/demos/common/pipeline.py b/demos/common/pipeline.py new file mode 100644 index 00000000..6ae9d672 --- /dev/null +++ b/demos/common/pipeline.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import contextlib +import itertools +from collections.abc import Callable, Generator, Iterable, Iterator +from threading import Condition, Lock, Thread +from typing import Generic, TypeVar, overload + +T = TypeVar("T") +U = TypeVar("U") + + +class MailboxShutDown(Exception): # noqa: N818 (An exception, but not an error) + """Exception to indicate that a Mailbox has been shut down. + + This will be raised if Mailbox.get() or Mailbox.put() is run on a + mailbox after its .shutdown() method has been called, or if it is + called while waiting. + """ + + def __init__(self, mailbox: Mailbox) -> None: + #: The mailbox that was shut down + self.mailbox = mailbox + + def __str__(self) -> str: + return f"Mailbox shut down: {self.mailbox}" + + +class Mailbox(Generic[T]): + """Thread-safe container to pass a single object at a time between threads. + + A Mailbox can be shut down to indicate that it is no longer + available. This can be used by a producer to indicate that no + more items will be forthcoming, or by a consumer to indicate that + it is no longer able to accept more objects. + + In Python 3.13, this has the same basic functionality as + queue.Queue(1). Prior to 3.13, there was no + queue.Queue.shutdown() method. The mechanisms for using mailboxes + as iterables, or adding items from iterables, are also not part of + queue.Queue in any version of Python. + """ + + def __init__(self) -> None: + #: Lock to protect mailbox state + self.lock = Lock() + self._condition = Condition(lock=self.lock) + #: Indicates whether an item is present in the mailbox + self.has_item = False + self._item: T | None = None + #: Indicates whether the mailbox has been shut down + self.is_shutdown = False + + def get(self) -> T: + """Return and remove the item being held by the mailbox. + + If an item is not presently available, block until another + thread calls .put(). + """ + with self._condition: + while True: + # We test to see if an item is present before testing if the queue is shut down. This is so that a + # non-immediate shutdown allows the mailbox to be drained. + if self.has_item: + rv = self._item + self._item = None # Don't hold an unnecessary reference + self.has_item = False + self._condition.notify_all() + return rv # type:ignore[return-value] + if self.is_shutdown: + raise MailboxShutDown(self) + self._condition.wait() + + def get_many(self) -> Iterable[T]: + """Yield items as they appear in the mailbox. + + The iterator exits the mailbox is shut down; MailboxShutDown + is not raised into the caller. + """ + return iter(self) + + def put(self, item: T) -> None: + """Store an item in the mailbox. + + If an item is already in the mailbox, block until another + thread calls .get(). + """ + with self._condition: + while True: + if self.is_shutdown: + raise MailboxShutDown(self) + if not self.has_item: + self._item = item + self.has_item = True + self._condition.notify() + return + self._condition.wait() + + def put_many(self, items: Iterable[T]) -> Iterator[T]: + """Put the elements of iterable in the mailbox, one at a time. + + If the mailbox is shut down before all the elements can be put + into it, a MailboxShutDown exception is _not_ raised. + + Returns an iterator containing any remaining items, including + the one that was being processed when the mailbox was shut + down. The first item (if any) of this iterator can be + immediately accessed with next; subsequent items defer to the + input iterable, so may block. + """ + iterator = iter(items) + for item in iterator: + # We put this try/except inside the for loop, to make sure we don't accidentally filter out an exception + # that escaped the items iterator. + try: + self.put(item) + except MailboxShutDown: + return itertools.chain([item], iterator) + # Remove references to the value once it's not needed. This lets objects with advanced buffer semantics + # reclaim the object's memory immediately, without waiting for the next iteration of the iterable. + del item + return iter([]) + + def shutdown(self, *, immediate: bool = False) -> None: + """Shut down the mailbox, marking it as unavailable for future use. + + Any callers currently blocked in .get or .put, or any future + caller to those methods, will recieve a MailboxShutDown + exception. Callers using .get_many or iterating over the + mailbox will see the iteration end. Callers to .put_many will + stop adding items. + + If immediate is False (the default), and an item is currently + in the mailbox, it will be returned by the next call to + .get(), and the one after that will raise MailboxShutDown. + + It is safe to call this method multiple times, including to + promote a non-immediate shutdown to an immediate one. + """ + with self._condition: + # We don't actually need to check whether we've been called already. + self.is_shutdown = True + if immediate: + self._item = None + self.has_item = False + self._condition.notify_all() + + def __iter__(self) -> Iterator[T]: + """Yield items as they appear in the mailbox. + + The iterator exits when the mailbox is shut down; + MailboxShutDown is not raised into the caller. + """ + with contextlib.suppress(MailboxShutDown): + while True: + yield self.get() + + +class PipelineStage(Thread, Generic[T, U]): + """A stage of a multi-threaded pipeline. + + The target function will be called once, and should yield one + value for each element. + + If an in_mailbox is provided, the function will get an iterable of + its successive elements. If an out_mailbox is provided, it will + be supplied with the successive outputs of the target function. + + If the either mailbox is shut down, the target function's loop + will stop being called. Both mailboxes will be shut down when the + target function ends. + + Note to readers adapting this class to their own programs: + + This is designed for linear pipelines: it is not meant to support + fan-in (multiple stages feeding one mailbox) or fan-out (one + mailbox feeding multiple stages). The shutdown semantics of these + sorts of pipelines will depend heavily on what it's used for, and + this demo only needs a simple pipeline. + """ + + # Source stage + @overload + def __init__( + self, + target: Callable[[], Generator[U]], + *, + out_mailbox: Mailbox[U], + name: str | None = None, + ) -> None: ... + + # Transformer stage + @overload + def __init__( + self, + target: Callable[[Iterable[T]], Generator[U]], + *, + in_mailbox: Mailbox[T], + out_mailbox: Mailbox[U], + name: str | None = None, + ) -> None: ... + + # Sink stage + @overload + def __init__( + self, + target: Callable[[Iterable[T]], None], + *, + in_mailbox: Mailbox[T], + name: str | None = None, + ) -> None: ... + + def __init__( + self, + target: Callable[[], Generator[U]] | Callable[[Iterable[T]], Generator[U]] | Callable[[Iterable[T]], None], + *, + in_mailbox: Mailbox[T] | None = None, + out_mailbox: Mailbox[U] | None = None, + name: str | None = None, + ) -> None: + """Initialize the PipelineStage. + + Either :param:`in_mailbox` or :param:`out_mailbox` is + required. Otherwise, it would be a pipeline stage that can't + connect to anything else. (You can always use + :class:`threading.Thread` directly if you need that behavior.) + + :param target: Function to run during the stage. This will be + called once, in a separate thread. This should take one + argument if :param:`in_mailbox` is provided, or no + arguments otherwise. If you want additional arguments + (such as configuration), use :func:`functools.partial`. + :param in_mailbox: An optional :class:`Mailbox` to provide + inputs to the target function. The target function will + be called with one argument, an iterable that you can use + in a for loop or similar construct, to get the successive + values. + :param out_mailbox: An optional :class:`Mailbox` to receive + outputs from the target function. If this is provided, + the target function must be a generator (a function that + uses ``yield`` instead of ``return``). The successive + outputs from the function will be placed in + :param:`out_mailbox`. + :param name: An optional name for debugging purposes; see + :attr:`threading.Thread.name`. + """ + if in_mailbox is None and out_mailbox is None: + msg = "Cannot have a pipeline stage with neither inputs nor outputs" + raise ValueError(msg) + self.in_mailbox = in_mailbox + self.out_mailbox = out_mailbox + self.target = target + #: The exception (if any) raised by the target function + self.exc: Exception | None = None + super().__init__(name=name, daemon=True) + + def run(self) -> None: + """Execute the pipeline stage. + + This should not be run directly. Instead, use the start() + method (inherited from threading.Thread) to run this in a + background thread. + + This will run the target function, managing input and output + mailboxes. When the stage completes, whether normally or with + an error, the mailboxes will be shut down. + """ + try: + if self.out_mailbox is None: + # This is a sink function, the easiest to deal with. Since a mailbox is iterable, we can just pass it + # to the target function. + assert self.in_mailbox is not None # noqa: S101 + self.target(self.in_mailbox) # type:ignore[call-arg] + return + # This is a source or transformation function. + out_iterable = self.target() if self.in_mailbox is None else self.target(self.in_mailbox) # type:ignore[call-arg] + if not isinstance(out_iterable, Generator): + msg = ( + "Pipeline target function was expected to be a generator; " + f"instead, it returned a {type(out_iterable)}." + ) + raise TypeError(msg) # noqa: TRY301 + # Once a generator is closed, the yield call (where they block when they send an object downstream) will + # raise GeneratorExit. That lets finally: blocks, with: exits, etc. run. This happens automatically when + # out_iterable is garbage-collected. We still close it explicitly to so it gets the GeneratorExit, in case + # something (like an exception object) is holding a reference to out_iterable. + with contextlib.closing(out_iterable): + self.out_mailbox.put_many(out_iterable) + except Exception as e: + # We store the exception, so that our caller can choose what to do about it after they call join. + self.exc = e + raise + finally: + if self.in_mailbox is not None: + self.in_mailbox.shutdown() + if self.out_mailbox is not None: + self.out_mailbox.shutdown() + + def __str__(self) -> str: + return f"" diff --git a/demos/tinytv-stream-simple.py b/demos/tinytv-stream-simple.py new file mode 100755 index 00000000..1046a5d3 --- /dev/null +++ b/demos/tinytv-stream-simple.py @@ -0,0 +1,182 @@ +#! /usr/bin/env python3 + +# You're the type of person who likes to understand how things work under the hood. You want to see a simple example +# of how to stream video to a TinyTV. This is that example! +# +# There's a more advanced example, tinytv-stream.py, that has more features and better performance. But this simple +# demo is easier to understand, because it does everything in a straightforward way, without any complicated features. +# +# Wait, what's a TinyTV? It's a tiny retro-style TV, about 5cm tall. You can put videos on it, or stream video to it +# over USB. Advanced users can even reprogram its firmware! You can find out more about it at https://tinytv.us/ +# +# You may want to read at least the docstring at the top of tinytv-stream.py, since it gives you some details about +# setting up permissions on Linux to connect to your TinyTV. +# +# We use three libraries that don't come with Python: PySerial, Pillow, and (of course) MSS. You'll need to install +# those with "pip install pyserial pillow mss". Normally, you'll want to install these into a venv; if you don't know +# about those, there are lots of great tutorials online. + +from __future__ import annotations + +import io +import sys +import time + +import serial +from PIL import Image + +import mss + + +def main() -> None: + # The TinyTV gets streaming input over its USB connection by emulating an old-style serial port. We can send our + # video to that serial port, in the format that the TinyTV expects. + # + # The advanced demo can find the correct device name by looking at the USB IDs of the devices. In this simple + # demo, we just ask the user to supply it. + if len(sys.argv) != 2: # noqa: PLR2004 + print( + f"Usage: {sys.argv[0]} DEVICE\n" + "where DEVICE is something like /dev/ttyACM0 or COM3.\n" + 'Use "python3 -m serial.tools.list_ports -v" to list your available devices.' + ) + sys.exit(2) + device = sys.argv[1] + + # Open the serial port. It's usually best to use the serial port in a "with:" block like this, to make sure it's + # cleaned up when you're done with it. + with serial.Serial(device, timeout=1, write_timeout=1) as ser: + # The TinyTV might have sent something to the serial port earlier, such as to a program that it was talking to + # that crashed without reading it. If that happens, these messages will still be in the device's input + # buffer, waiting to be read. We'll just delete anything waiting to be read, to get a fresh start. + ser.reset_input_buffer() + + # Let's find out what type of TinyTV this is. The TinyTV has a special command to get that. + ser.write(b'{"GET":"tvType"}') + tvtype_response = ser.readline() + print("Received response:", tvtype_response.strip()) + + # The response is usually something like {"tvType":TinyTV2}. Normally, you'd want to use json.loads to parse + # JSON. But this isn't correct JSON (there's no quotes around the TV type), so we can't do that. + # + # But we still need to know the TV type, so we can figure out the screen size. We'll just see if the response + # mentions the right type. + if b"TinyTV2" in tvtype_response: + tinytv_size = (210, 135) + elif b"TinyTVKit" in tvtype_response: + tinytv_size = (96, 64) + elif b"TinyTVMini" in tvtype_response: + tinytv_size = (64, 64) + else: + print("This doesn't seem to be a supported type of TinyTV.") + sys.exit(1) + print("Detected TinyTV with screen size", tinytv_size) + + # We're ready to start taking screenshots and sending them to the TinyTV! Let's start by creating an MSS + # object. Like the serial object, we use a "with:" block to make sure that it can clean up after we're done + # with it. + # + # Note that we use the same MSS object the whole time. We don't try to keep creating a new MSS object each + # time we take a new screenshot. That's because the MSS object has a lot of stuff that it sets up and + # remembers, and creating a new MSS object each time would mean that it has to repeat that setup constantly. + with mss.MSS() as sct: + # It's time to get the monitor that we're going to capture. In this demo, we just capture the first + # monitor. (We could also use monitors[0] for all the monitors combined.) + monitor = sct.monitors[1] + print("Monitor:", monitor) + + # The rest of this will run forever, until we get an error or the user presses Ctrl-C. Let's record our + # starting time and count frames, so we can report FPS at the end. + start_time = time.perf_counter() + frame_count = 0 + try: + while True: + # First, we get a screenshot. MSS makes this easy! + screenshot = sct.grab(monitor) + + # The next step is to resize the image to fit the TinyTV's screen. There's a great image + # manipulation library called PIL, or Pillow, that can do that. Let's transfer the raw pixels in + # the ScreenShot object into a PIL Image. + original_image = Image.frombytes("RGB", screenshot.size, screenshot.bgra, "raw", "BGRX") + + # Now, we can resize it. The resize method may stretch the image to make it match the TinyTV's + # screen; the advanced demo gives other options. Using a reducing gap is optional, but speeds up + # the resize significantly. + scaled_image = original_image.resize(tinytv_size, reducing_gap=3.0) + + # The TinyTV wants its image frames in JPEG format. PIL can save an image to a JPEG file, but we + # want the JPEG data as a bunch of bytes we can transmit to the TinyTV. Python provides + # io.BytesIO to make something that pretends to be a file to PIL, but lets you just get the bytes + # that PIL writes. + with io.BytesIO() as fh: + scaled_image.save(fh, format="JPEG") + jpeg_bytes = fh.getvalue() + + # We're ready to send the frame to the TinyTV! First, though, this is a good time to look for any + # error messages that the TinyTV has sent us. In today's firmware, anything the TinyTV sends us + # is always an error message; it doesn't send us anything normally. (Of course, this might change + # in later firmware versions, so we may need to change this someday.) + if ser.in_waiting != 0: + # There is indeed an error message. Let's read it and show it to the user. + incoming_data = ser.read(ser.in_waiting) + print(f"Error from TinyTV: {incoming_data!r}") + sys.exit(1) + + # The TinyTV wants us to send a command to tell it that we're about to send it a new video frame. + # We also need to tell it how many bytes of JPEG data we're going to send. The command we send + # looks like {"FRAME":12345}. + delimiter = b'{"FRAME":%i}' % len(jpeg_bytes) + ser.write(delimiter) + + # Now that we've written the command delimiter, we're ready to write the JPEG data. + ser.write(jpeg_bytes) + + # Once we've written the frame, update our counter. + frame_count += 1 + + # Now we loop! This program will keep running forever, or until you press Ctrl-C. + + finally: + # When the loop exits, report our stats. + end_time = time.perf_counter() + run_time = end_time - start_time + print("Frame count:", frame_count) + print("Time (secs):", run_time) + if run_time > 0: + print("FPS:", frame_count / run_time) + + +# Thanks for reading this far! Let's talk about some improvements; these all appear in the advanced version. +# +# * Right now, the user has to figure out the right device name for the TinyTV's serial port and supply it on the +# command line. The advanced version can find the right device automatically by looking at the USB IDs of the +# connected devices. +# +# * There are a lot of things the user might want to do differently, such as choosing which monitor to capture, or +# changing the JPEG quality (which can affect how fast the TinyTV can process it). The advanced version uses +# argparse to provide command-line options for these things. +# +# * The advanced program shows a status line with things like the current FPS. +# +# * Programs of any significant size have a lot of common things you usually want to think about, like organization +# into separate functions and classes, error handling, logging, and so forth. In this simple demo, we didn't worry +# about those, but they're important for a real program. +# +# * Here's the biggest difference, though. In the program above, we do a lot of things one at a time. First we take +# a screenshot, then we resize it, then we send it to the TinyTV. +# +# We could overlap these, though: while we're sending one screenshot to the TinyTV, we could be preparing the next +# one. This can speed up the program from about 15 fps to about 25 fps, which is about as fast as the TinyTV can +# run! +# +# This is called a pipeline. While it's tough to coordinate, just like it's harder to coordinate a group of people +# working together than to do everything yourself, it also can be much faster. A lot of the code in the advanced +# version is actually about managing the pipeline. +# +# Using a pipeline isn't always helpful: you have to understand which operations the system can run in parallel, and +# how Python itself coordinates threads. That said, I do find that many times, if I'm using MSS to capture video, +# it does benefit from pipelining these three stages: taking a screenshot, processing it, and sending it somewhere +# else (like a web server or an AVI file). + +if __name__ == "__main__": + main() diff --git a/demos/tinytv-stream.py b/demos/tinytv-stream.py new file mode 100755 index 00000000..351eebfd --- /dev/null +++ b/demos/tinytv-stream.py @@ -0,0 +1,712 @@ +#! /usr/bin/env python3 + +"""Stream to a TinyTV + +A TinyTV is a roughly 5cm tall TV with a 2cm screen. It can play +videos from built-in storage, or a computer can stream video to it. + +This program will capture your display, and stream it to the TinyTV. + +While streaming is supported with the TinyTV 2, Mini, and DIY Kit, +this has only been tested with the TinyTV 2. Reports regarding tests +with other devices are welcome! + +The firmware code in the TinyTV that we're talking to is at +https://github.com/TinyCircuits/TinyCircuits-TinyTVs-Firmware/blob/master/USB_CDC.h + +In short, the TinyTV takes its input as Motion JPEG (MJPG), a simple +sequence of frames, each encoded as a single JPEG file. With JPEG, it +is difficult to tell where one JPEG image ends and the next begins. +So, each frame is preceded by a delimiter: this is the JSON text +{"FRAME":1234}, where the number is the size in bytes. This is +followed by the JPEG data itself. + +How fast can it be? The time it takes for the TinyTV to process the +JPEG seems to be the main bottleneck. In our test, the official +streamer at https://tinytv.us/Streaming/ gets about 15 fps for a 4k +capture, which is about the same as the non-threaded simple demo gets +(depending on the screen contents). + +We use a background sending thread so that we can prepare one +screenshot while the other is being sent. That lets us get about +20-30 fps for 4k in our tests, which seems to be close to the limit of +the TinyTV hardware (again, depending on the JPEG settings and screen +contents). + +Before connecting, we can't distinguish between a TinyTV and any other +Raspberry Pi Pico by USB IDs alone. You may need to use the --device +or --usb-serial-number flag to tell the program which serial device to +use. Different OSs have different ways to identify the correct +device. + +Windows: + +On Windows, the serial device will be something like COM3. You can +find the correct port by looking in Device Manager under "Ports (COM & +LPT)". You can specify the device to use with the --device flag. + +You may want to use the --list-devices flag to identify the correct +device, and use the --usb-serial-number flag in future invocations. +This is because Windows COM port assignments can change between +reboots or replugging the device. + +macOS: + +On macOS, the serial device is usually something like +/dev/tty.usbserial-1234ABCD or /dev/tty.usbmodem1234ABCD, where +1234ABCD is a device-specific value that will be the same every time +that device is plugged in. You can use the --device flag to point to +these. + +Linux: + +On Linux, the serial device is usually something like /dev/ttyACM0 or +/dev/ttyUSB0. You can use the --device flag to point to the symlink +that is automatically created, e.g., +/dev/serial/by-id/usb-Raspberry_Pi_Pico_0123456789ABCDEF-if00. + +You need write access to the serial device that represents the TinyTV. +If you run this program as a normal user, you may need to set up a +udev rule to give your user access. + +You can do this by creating a file named +/etc/udev/rules.d/70-tinytv.rules or something similar. Name it with +a number below 73 so it runs before 73-seat-late.rules (where uaccess +is applied). (Note that this rule will be applied to many Raspberry +Pi Pico devices; you can add a ATTRS{serial} test to limit it to just +your TinyTV.) + + # TinyTV2, TinyTVKit + SUBSYSTEM=="tty", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="0003|000a", TAG+="uaccess" + # TinyTVMini + SUBSYSTEM=="tty", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="8009", TAG+="uaccess" + +Once you have the rule in place, reload udev rules: + + sudo udevadm control --reload-rules + +Then unplug and replug your TinyTV. +""" + +# I see you're the type of person who likes to read the source code, instead of just the documentation. Maybe you're +# looking here because you're getting ideas about doing something similar in your own projects, or maybe you're just +# curious about how this works. Terrific! Glad to have you with us! +# +# You may want to read the simple demo first, to get an idea of what we're doing here. This program follows the same +# basic steps, but we organize them into a multithreaded pipeline for higher performance. We also add more +# configuration options and things like that. +# +# Still, the basic flow is still straightforward: +# Capture from MSS -> Scale and JPEG-encode in PIL -> Send to serial port +# +# The core idea behind this program is a pipeline: instead of fully processing one video frame at a time, we work on +# several different frames at once, each at a different stage of processing. +# +# At any given moment, one frame might be getting captured, the previous frame might be getting scaled and +# JPEG-encoded, and an even earlier frame might be in the process of being sent to the TinyTV. +# +# The stages are: +# +# * capture a screenshot (MSS) +# * scale and JPEG-encode it (Pillow) +# * send it to the TinyTV (serial) +# +# Between each stage is a mailbox. A mailbox can hold one item. A stage puts its output into the next mailbox, and +# the following stage takes it when it's ready. +# +# If a stage tries to read from an empty mailbox, it waits. If it tries to write to a full mailbox, it also waits. +# +# This lets the stages overlap. While one frame is being sent to the TinyTV (the slowest step), the next frame can +# already be captured or encoded. +# +# Eventually, the slowest stage determines the overall speed. When that happens, earlier stages naturally stop and +# wait. This is called backpressure: work piles up behind the bottleneck instead of running ahead and wasting effort. +# +# The result is that the TinyTV may show a frame that's a few frames behind what's currently on your screen. That +# latency is the cost of keeping the pipeline efficient. +# +# An alternative design would be to drop old frames when a mailbox is full, so the display stays closer to "live". +# That reduces lag, but it means capturing and encoding frames that are never shown. Which approach is better depends +# on your goals; this program chooses to block and apply backpressure. + +from __future__ import annotations + +import argparse +import functools +import io +import logging +import os +import re +import sys +import time +from collections import deque +from typing import TYPE_CHECKING, Literal + +import serial +from PIL import Image, ImageOps +from prettytable import PrettyTable, TableStyle +from serial.tools import list_ports + +import mss + +from common.pipeline import Mailbox, PipelineStage + +if TYPE_CHECKING: + from collections.abc import Generator, Iterable + +# The keys in this are substrings in the tvType query. Make sure that they're all distinct: having both "TinyTV2" and +# "TinyTV2.1" in here would mean that a 2.1 might be misidentified as a 2. We use substrings instead of parsing the +# response because the TinyTV currently responds with invalid JSON, and they might change that later. +SUPPORTED_DEVICES = { + # Only the TinyTV2 kit has been tested. Reports with other hardware are welcome! + b"TinyTV2": { + # Uses an RP2040 board. 2e8a:000a is the ID when it's in normal mode (not recovery), which is the default + # VID:PID for an RP2040. + "usb_id": (0x2E8A, 0x000A), + "size": (210, 135), + }, + b"TinyTVKit": { + # Uses an RP2040 board, like the TinyTV2. I assume it also uses the default VID:PID. + "usb_id": (0x2E8A, 0x000A), + "size": (96, 64), + }, + b"TinyTVMini": { + # Uses a board based on the SAMD21, similar to an Arduino Zero. From what I see in the TinyCircuits Arduino + # board file, it enumerates as 03eb:8009. I think it uses 03eb:8008 in recovery mode. + "usb_id": (0x03EB, 0x8009), + "size": (64, 64), + }, +} + +# Downscaling is one of the most time-intensive steps, and the practical difference between the high-quality +# algorithms isn't going to be perceptible in this context. +SCALING_ALGORITHM = Image.Resampling.HAMMING + +# When choosing JPEG quality, note that higher quality images are slower for the TinyTV to decode; you can get a few +# extra FPS by lowering the quality to something like 35. However, with some content (like text windows, which the +# user will have on their screen when they first start this program), heavy compression makes JPEG artifacts really +# visible. You just don't want to see that on your TinyTV. +DEFAULT_JPEG_QUALITY = 75 + + +LOGGER = logging.getLogger("tinytv-stream") + + +def list_devices() -> None: + """Display all USB serial ports in a formatted table.""" + ports = list(list_ports.comports()) + if not ports: + print("No serial ports found.") + return + + # Create and populate table + table = PrettyTable(["Device", "USB ID", "Serial Number", "Manufacturer", "Product", "Description"]) + table.align = "l" + table.set_style(TableStyle.PLAIN_COLUMNS) + table.sortby = "Device" + for port in ports: + usb_id = f"{port.vid:04x}:{port.pid:04x}".lower() if port.vid and port.pid else "" + serial_num = port.serial_number or "" + table.add_row( + [ + port.device, + usb_id, + serial_num, + port.manufacturer or "", + port.product or "", + port.description if port.description and port.description != "n/a" else "", + ] + ) + + print(table) + + +def get_device_name(usb_id: str | None, usb_serial_number: str | None) -> str: # noqa: PLR0912 + """Find the device name for a USB serial port. + + If multiple serial ports match the criteria, an exception is + raised. + + We currently don't provide the user a way to select an interface + if the device has multiple USB endpoints. The TinyTV doesn't do + that, so it's not urgent. + + :param usb_id: USB vendor:product ID in format "vvvv:pppp". + :param usb_serial_number: Optional USB serial number to filter by. + :returns: The device name (e.g., "/dev/ttyACM0" on Linux or "COM3" + on Windows). + """ + if usb_id is not None: + vendor_str, product_str = usb_id.lower().split(":", maxsplit=1) + vendor = int(vendor_str, 16) + product = int(product_str, 16) + + candidates = [] + + # We sort the ports by name so that the --verbose output is nicer to read. + for port in sorted(list_ports.comports(), key=lambda port: port.name): + if port.vid is None or port.pid is None: + LOGGER.debug("%s: device is not USB", port.name) + continue + if usb_serial_number is not None and port.serial_number != usb_serial_number: + LOGGER.debug("%s: serial number does not match (found %r)", port.name, port.serial_number) + continue + if usb_id is not None: + if (port.vid, port.pid) == (vendor, product): + LOGGER.debug("%s: device matches") + candidates.append(port) + else: + LOGGER.debug("%s: USB id mismatch: %04x:%04x", port.name, port.vid, port.pid) + else: + for device_name, device_spec in SUPPORTED_DEVICES.items(): + if (port.vid, port.pid) == device_spec["usb_id"]: + LOGGER.debug( + "%s: USB id matches %s: %04x:%04x", port.name, device_name.decode("ascii"), port.vid, port.pid + ) + candidates.append(port) + break + else: + LOGGER.debug("%s: USB id not in supported device list: %04x:%04x", port.name, port.vid, port.pid) + + if len(candidates) == 1: + # We've been logging the name attribute, which is the human-friendly name: "ttyACM0". We return the device + # attribute, which is the full path: "/dev/ttyACM0". + return candidates[0].device + + msg = "Cannot find USB device" if len(candidates) == 0 else "Multiple USB devices found" + if usb_id is not None: + msg += f": {usb_id}" + else: + msg += " in supported device list" + if usb_serial_number is not None: + msg += f" with serial number {usb_serial_number}" + if len(candidates) != 0: + msg += f": {', '.join(c.name for c in candidates)}" + + msg += "\nHint: Consider --list-devices, find your device, and use the --device flag." + + raise RuntimeError(msg) + + +def get_screen_size(ser: serial.Serial) -> tuple[int, int]: + """Identify the TinyTV type and screen size. + + :param ser: An open serial connection to the TinyTV. + :return: The screen size as (width, height). + """ + # First, clear out any remaining junk in the buffer, such as from earlier buggy runs. + while ser.in_waiting: + ser.reset_input_buffer() + # Check for the device type. + LOGGER.debug('>>> {"GET":"tvType"}') + ser.write(b'{"GET":"tvType"}') + response = ser.readline() # {"tvType":TinyTV2}\r\n + LOGGER.debug("<<< %s", response.decode(errors="replace").rstrip()) + # Do a very simple check: the return format might change (such as to add quotes around the value). + for name, spec in SUPPORTED_DEVICES.items(): + if name in response: + LOGGER.debug("Device detected as %s", name.decode()) + return spec["size"] + msg = f"Device is not a supported TinyTV: {ser.name}" + raise RuntimeError(msg) + + +def _scale_letterbox(img: Image.Image, size: tuple[int, int]) -> Image.Image: + """Fit image to size, preserving aspect ratio, with black padding.""" + img.thumbnail(size, SCALING_ALGORITHM) + return ImageOps.pad(img, size, color="black") + + +def _scale_crop(img: Image.Image, size: tuple[int, int]) -> Image.Image: + """Crop image to fit size, preserving aspect ratio.""" + return ImageOps.fit(img, size, SCALING_ALGORITHM) + + +def _scale_stretch(img: Image.Image, size: tuple[int, int]) -> Image.Image: + """Stretch image to exactly fit size, ignoring aspect ratio.""" + return img.resize(size, SCALING_ALGORITHM) + + +def capture_image( + *, + monitor: int | None = None, + capture_area: dict[str, int] | None = None, +) -> Generator[Image.Image]: + """Continuously capture images from the specified monitor. + + Either monitor or capture_area must be used, but not both. + + :param monitor: Monitor number to capture from, using the standard + MSS convention (all screens=0, first screen=1, etc.). + :param capture_area: Capture rectangle dict with 'left', 'top', + 'width', 'height'. + :yields: PIL Image objects from the captured monitor. + """ + with mss.MSS() as sct: + rect = capture_area if capture_area is not None else sct.monitors[monitor] + LOGGER.debug("Capture area: %i,%i, %ix%i", rect["left"], rect["top"], rect["width"], rect["height"]) + + while True: + sct_img = sct.grab(rect) + pil_img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") + yield pil_img + + +def process_and_encode_image( + images: Iterable[Image.Image], + *, + size: tuple[int, int], + scaling_mode: Literal["letterbox", "crop", "stretch"] = "stretch", + quality: int = DEFAULT_JPEG_QUALITY, +) -> Generator[bytes]: + """Scale and JPEG-encode images for TinyTV display. + + :param images: Iterable of PIL Image objects to process. + :param size: Tuple (width, height) to resize images to. + :param scaling_mode: How to scale images ("letterbox", "crop", or + "stretch"). + :param quality: JPEG quality level (1-100). Higher quality + provides clearer images, but also is slower for the TinyTV to + process. + :yields: JPEG-encoded image data as bytes. + """ + # Select the scaling function based on mode. + scale_func = { + "letterbox": _scale_letterbox, + "crop": _scale_crop, + "stretch": _scale_stretch, + }[scaling_mode] + + for img in images: + # Scaling large images can be slow. To speed it up, reduce to ~3x target size before invoking the + # high-quality scaling function. + reduced_size = tuple(d * 3 for d in size) + reduction_factor = max(1, img.width // reduced_size[0], img.height // reduced_size[1]) + scaled_img = img.reduce(reduction_factor) + scaled_img = scale_func(scaled_img, size) + + with io.BytesIO() as fh: + scaled_img.save(fh, format="JPEG", quality=quality) + jpeg_bytes = fh.getvalue() + + yield jpeg_bytes + + +def send_jpeg(ser: serial.Serial, jpeg_bytes_inputs: Iterable[bytes]) -> Generator[int]: + """Send JPEG frames to TinyTV over its USB serial connection. + + :param ser: Serial device for an open and verified connection. + :param jpeg_bytes_inputs: Iterable of JPEG-encoded image data. Each + element should represent a single, self-contained image frame. + :yields: Byte count sent for each frame (delimiter + JPEG data). + """ + for jpeg_bytes in jpeg_bytes_inputs: + # The TinyTV doesn't have an unambiguous error protocol: it just prints an English string. Fortunately, it + # doesn't print anything during normal operation. Debug builds of the firmware can, but if you're using a + # debug build, you know enough to adapt this code to your needs. + if ser.in_waiting: + # Configure a one-second timeout on the serial device, so that it will stop reading after that time, + # instead of waiting for a full 4k of error messages. + ser.timeout = 1 + incoming_data = ser.read(4096) + msg = f"Error from TinyTV: {incoming_data!r}" + raise RuntimeError(msg) + + delimiter = ('{"FRAME":%s}' % len(jpeg_bytes)).encode("ascii") # noqa: UP031 + ser.write(delimiter) + ser.write(jpeg_bytes) + yield (len(delimiter) + len(jpeg_bytes)) + + +def show_stats(byte_counts: Iterable[int]) -> None: + """Display streaming statistics (FPS and throughput). + + Statistics are displayed over a 100-frame sliding window, which is + about four seconds. + + FPS indicates how fast the entire pipeline can run as a whole, not + any individual stage. + + Bps, or bytes per second, is the speed at which we are sending + data to the TinyTV. The TinyTV is usually the slowest part of the + pipeline, but not because of the raw transfer speed. If you try + different --quality values, you'll see that at higher quality, the + Bps goes up, but the overall FPS drops. This indicates that the + per-frame decoding in the TinyTV, rather than the raw transfer + speed, is the limiting factor. + + This is run on the main thread. This is partly a matter of + convenience, and partly because it simplifies waiting for the + pipeline to complete. + + :param byte_counts: Iterable of byte counts per frame. + """ + # If we needed high-precision, such as for benchmarking very short times, we might want to use time.perf_counter(). + # However, time.monotonic() is sufficient for simple stats reporting. + start_time = time.monotonic() + time_deque: deque[float] = deque(maxlen=100) + byte_count_deque: deque[int] = deque(maxlen=100) + next_display_update = 0.0 + last_status_len = 0 + for frame_count, byte_count in enumerate(byte_counts): + now = time.monotonic() + time_deque.append(now) + byte_count_deque.append(byte_count) + if now >= next_display_update and len(time_deque) > 1: + next_display_update = now + 0.1 + running_time = now - start_time + running_minutes = int(running_time / 60) + running_seconds = int(running_time % 60) + window_secs = time_deque[-1] - time_deque[0] + window_frames = len(time_deque) + window_bytes = sum(byte_count_deque) + fps = window_frames / window_secs + bytes_per_sec = int(window_bytes / window_secs) + line = ( + f"{running_minutes:02d}:{running_seconds:02d} frame {frame_count}: {fps:.2f} fps, {bytes_per_sec} Bps" + ) + this_status_len = len(line) + full_line = f"\r{line}{' ' * (last_status_len - this_status_len)}" + print(full_line, end="") + last_status_len = this_status_len + + +def _usb_id_type(value: str) -> str: + """Validate and return a USB ID in vvvv:pppp format. + + This is used to tell argparse how to validate the string given on + the command line. + + :param value: The USB ID string to validate. + :returns: The validated USB ID string. + :raises argparse.ArgumentTypeError: If the format is invalid. + """ + # Expect vvvv:pppp using hex digits + if re.fullmatch(r"[0-9a-fA-F]{4}:[0-9a-fA-F]{4}", value): + return value + msg = "Invalid USB ID format; expected vvvv:pppp (hex)" + raise argparse.ArgumentTypeError(msg) + + +def _quality_type(value: str) -> int: + """Validate and return a JPEG quality value (1-100). + + This is used to tell argparse how to validate the string given on + the command line. + + :param value: The quality value string to validate. + :returns: The validated quality value as an integer. + :raises argparse.ArgumentTypeError: If the value is not between 1 + and 100. + """ + try: + q = int(value) + except ValueError: + msg = "Quality must be an integer between 1 and 100" + raise argparse.ArgumentTypeError(msg) from None + if 1 <= q <= 100: # noqa: PLR2004 + return q + msg = "Quality must be between 1 and 100" + raise argparse.ArgumentTypeError(msg) + + +def _capture_area_type(value: str) -> dict[str, int]: + """Validate and return a capture area dict. + + Expected format is ``left,top,width,height`` where all values are + integers. + + :param value: The capture area string to validate. + :returns: Dict with 'left', 'top', 'width', 'height' keys. + :raises argparse.ArgumentTypeError: If the format is invalid or extents + are non-positive. + """ + parts = value.split(",") + if len(parts) != 4: # noqa: PLR2004 + msg = "Capture area must have four comma-separated integers: left,top,width,height" + raise argparse.ArgumentTypeError(msg) + + try: + left, top, width, height = (int(part) for part in parts) + except ValueError: + msg = "Capture area values must be integers" + raise argparse.ArgumentTypeError(msg) from None + + if width <= 0 or height <= 0: + msg = "Capture area width and height must be positive" + raise argparse.ArgumentTypeError(msg) + + return {"left": left, "top": top, "width": width, "height": height} + + +def main() -> None: + """Main entry point for the TinyTV streaming application. + + Parses command-line arguments, sets up the streaming pipeline, and + runs the capture-process-send stages in parallel threads. + """ + parser = argparse.ArgumentParser( + description="Stream your display to a TinyTV", + usage=""" +%(prog)s --list-devices +%(prog)s + [ [ --usb-id VID:PID ] [ --usb-serial-number SERIAL ] | --device DEVICE ] + [ --monitor MONITOR_NUMBER | --capture-area X,Y,R,B ] + [ --scaling-mode {letterbox,crop,stretch} ] [ --quality QUALITY ] +""".strip(), + ) + parser.add_argument( + "-L", + "--list-devices", + action="store_true", + help="List all USB serial ports and exit", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Report additional details", + ) + parser.add_argument( + "-U", + "--usb-id", + type=_usb_id_type, + help="USB VID:PID to search for (default 2e8a:000a)", + ) + parser.add_argument( + "-S", + "--usb-serial-number", + help="Match device by USB serial number instead of VID:PID", + ) + sample_device = ( + "COM3" + if os.name == "nt" + else "/dev/serial/by-id/usb-Raspberry_Pi_Pico_0123456789ABCDEF-if00" + if os.name == "posix" + else None + ) + sample_device_desc = f" (e.g., {sample_device})" if sample_device else "" + parser.add_argument( + "-d", + "--device", + help=(f"Serial device{sample_device_desc}"), + ) + monitor_group = parser.add_mutually_exclusive_group() + monitor_group.add_argument( + "-m", + "--monitor", + type=int, + default=1, + help="Monitor index from mss (0 = all, 1+ = individual; default 1; mutually exclusive with --capture-area)", + ) + monitor_group.add_argument( + "-a", + "--capture-area", + type=_capture_area_type, + metavar="X,Y,W,H", + help="Capture rectangle as left,top,width,height (mutually exclusive with --monitor)", + ) + parser.add_argument( + "-s", + "--scaling-mode", + choices=["letterbox", "crop", "stretch"], + default="crop", + help="How to scale to TinyTV display: letterbox (black bars), crop (center), or stretch (default crop)", + ) + parser.add_argument( + "-q", + "--quality", + type=_quality_type, + default=75, + help="JPEG quality (1-100; default 75)", + ) + + args = parser.parse_args() + + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s") + + # Handle --list-devices + if args.list_devices: + list_devices() + return + + # Compute variables from CLI args. + monitor = args.monitor + capture_area = args.capture_area + quality = args.quality + scaling_mode = args.scaling_mode + + # Find the right device. + if args.device: + if args.usb_id is not None or args.usb_serial_number is not None: + parser.error("argument --device: not allowed with --usb-id or --usb-serial-number") + device = args.device + else: + device = get_device_name(args.usb_id, args.usb_serial_number) + LOGGER.info("Using device %s", device) + + # We could use serial.Serial as a context manager if we wanted to automatically close it when we don't need it + # anymore. But we need it for the entire life of the program, so we just keep it open. + ser = serial.Serial(device, timeout=1) + size = get_screen_size(ser) + LOGGER.debug("TinyTV screen size: %dx%d", size[0], size[1]) + + # We divide our work into three stages: capture, processing (scale and encode), and sending. These each take + # about the same amount of time per-image. In the capture stage, we are mostly waiting for the image to be + # copied. In the processing stage, we are just running PIL image manipulation functions. In the send stage, we + # are mostly waiting for the TinyTV to read our data. The overall slowest stage is the send stage. You can get + # close to optimal performance even if you combine the capture and processing threads, but separating them gives + # us more headroom. + + # Mailboxes are used to pass data between threads. + captured_image_mailbox: Mailbox[Image.Image] = Mailbox() + jpeg_bytes_mailbox: Mailbox[bytes] = Mailbox() + byte_count_mailbox: Mailbox[int] = Mailbox() + + # The stages are run in parallel threads. + capture_stage: PipelineStage[None, Image.Image] = PipelineStage( + name="capture", + target=functools.partial(capture_image, monitor=monitor, capture_area=capture_area), + out_mailbox=captured_image_mailbox, + ) + process_and_encode_stage = PipelineStage( + name="process_and_encode", + in_mailbox=captured_image_mailbox, + target=functools.partial(process_and_encode_image, size=size, scaling_mode=scaling_mode, quality=quality), + out_mailbox=jpeg_bytes_mailbox, + ) + send_stage: PipelineStage[bytes, int] = PipelineStage( + name="send", + in_mailbox=jpeg_bytes_mailbox, + target=functools.partial(send_jpeg, ser), + out_mailbox=byte_count_mailbox, + ) + + capture_stage.start() + process_and_encode_stage.start() + send_stage.start() + + LOGGER.debug("Capture thread: %i", capture_stage.native_id) + LOGGER.debug("Process thread: %i", process_and_encode_stage.native_id) + LOGGER.debug("Send thread: %i", send_stage.native_id) + + # The show_stats function will run until the byte_count_mailbox shuts down, which happens if any of the threads + # encounters an error: the PipelineStage will shut down its mailboxes, and that shutdown will propagate through + # all the stages. + show_stats(byte_count_mailbox) + + # At this point, the byte_count_mailbox has shut down, and the others will be shutting down as well. We join the + # outstanding threads, so that if any of them raise an Exception, that thread has time to print it before we exit. + capture_stage.join() + process_and_encode_stage.join() + send_stage.join() + + # Test for errors from any of the stages. If there are errors, then the default threading.excepthook will have + # already printed it to stderr. We just need to exit with a non-zero value to let the shell know that something + # happened. (Mind you, currently, we never stop without an exception like KeyboardInterrupt.) + if capture_stage.exc or process_and_encode_stage.exc or send_stage.exc: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/demos/video-capture-simple.py b/demos/video-capture-simple.py new file mode 100755 index 00000000..7bfc4a05 --- /dev/null +++ b/demos/video-capture-simple.py @@ -0,0 +1,175 @@ +#! /usr/bin/env python3 + +# A lot of people want to use MSS to record a video of the screen. Doing it really well can be difficult - there's a +# reason OBS is such a significant program - but the basics are surprisingly easy! +# +# There's a more advanced example, video-capture.py, that has more features, and better performance. But this simple +# demo is easier to understand, because it does everything in a straightforward way, without any complicated features. +# +# Here, we're going to record the screen for 10 seconds, and save the result in capture.mp4, as an H.264 video stream. +# +# Sometimes, in film, cameramen will "undercrank", filming the action at a slower frame rate than how it will +# eventually be projected. In that case, motion appears artificially sped up, either for comedy (like the Benny Hill +# TV show), or for fast and frenetic action (like Mad Max: Fury Road). +# +# In this demo, we put in the file a marker saying that it's at 30 fps. But since this is a simple demo, your +# computer might not be able to keep up with writing video frames at that speed. In that case, you'll see the same +# effect: sped-up motion. +# +# The full demo has several techniques to mitigate that. First, it uses pipelined threads to let the video encoder +# use a full CPU core (often more, internally), rather than having to share a CPU core with all the other tasks. +# Second, it puts a timestamp marker on each frame saying exactly when it's supposed to be shown, rather than just +# saying to show all the frames at 30 fps. +# +# For this simple demo, though, we just record the frames and add them to the file one at a time. +# +# We use three libraries that don't come with Python: Pillow, PyAV, and (of course) MSS. You'll need to install those +# with "pip install pillow av mss". Normally, you'll want to install these into a venv; if you don't know about +# those, there are lots of great tutorials online. + +import logging +import time + +# Install the necessary libraries with "pip install av mss pillow". +import av +from PIL import Image + +import mss + +# These are the options you'd give to ffmpeg that would affect the way the video is encoded. There are comments in +# the full demo that go into more detail. +CODEC_OPTIONS = { + "profile": "high", + "preset": "medium", + "b": "6M", + "rc-lookahead": "40", +} + +# We'll try to capture at 30 fps, if the system can keep up with it (typically, that's possible at 1080p, but not at +# 4k). Regardless of what the system can keep up with, we'll mark the file as being at 30 fps. +FPS = 30 + +# The program will exit after 10 seconds of recording. +CAPTURE_SECONDS = 10 + +# Within an MP4 file, the video can be stored in a lot of different formats. In this demo, we use H.264, since it's +# the most widely supported. +# +# In ffmpeg, and the av libraries that we use here, the best codec for H.264 that doesn't require any specific +# hardware is libx264. There are faster ones that are hardware-accelerated, such as h264_nvenc which uses specialized +# chips on Nvidia video cards. +CODEC = "libx264" + +FILENAME = "capture.mp4" + + +def main() -> None: + logging.basicConfig(level=logging.DEBUG) + # If we don't enable PyAV's own logging, a lot of important error messages from libav won't be shown. + av.logging.set_level(av.logging.VERBOSE) + + with mss.MSS() as sct: + monitor = sct.monitors[1] + + # Because of how H.264 video stores color information, libx264 requires the video size to be a multiple of + # two. + monitor["width"] = (monitor["width"] // 2) * 2 + monitor["height"] = (monitor["height"] // 2) * 2 + + with av.open(FILENAME, "w") as avmux: + # The "avmux" object we get back from "av.open" represents the MP4 file. That's a container that holds + # the video, as well as possibly audio and more. These are each called "streams". We only create one + # stream here, since we're just recording video. + video_stream = avmux.add_stream(CODEC, rate=FPS, options=CODEC_OPTIONS) + video_stream.width = monitor["width"] + video_stream.height = monitor["height"] + # There are more options you can set on the video stream; the full demo uses some of those. + + # Count how many frames we're capturing, so we can log the FPS later. + frame_count = 0 + + # Mark the times when we start and end the recording. + capture_start_time = time.monotonic() + capture_end_time = capture_start_time + CAPTURE_SECONDS + + # MSS can capture very fast, and libav can encode very fast, depending on your hardware and screen size. + # We don't want to capture faster than 30 fps (or whatever you set FPS to). To slow down to our desired + # rate, we keep a variable "next_frame_time" to track when it's time to track the next frame. + # + # Some programs will just sleep for 1/30 sec in each loop. But by tracking the time when we want to + # capture the next frame, instead of always sleeping for 1/30 sec, the time that is spent doing the + # capture and encode (which can be substantial) is counted as part of the total time we need to delay. + next_frame_time = capture_start_time + + print("Capturing to", FILENAME, "for", CAPTURE_SECONDS, "seconds") + while True: + # Wait until we reach the time for the next frame. + while (now := time.monotonic()) < next_frame_time: + time.sleep(next_frame_time - now) + + # Try to capture the next frame 1/30 sec after our target time for this frame. We update this based + # on the target time instead of the actual time so that, if we were a little slow capturing this + # frame, we'll be a little fast capturing the next one, and even things out. (There's a slightly + # better, but more complex, way to update next_frame_time in the full demo.) + next_frame_time = next_frame_time + 1 / FPS + + # See if we've finished the requested capture duration. + if now > capture_end_time: + break + + # Print dots for each frame, so you know it's not frozen. + print(".", end="", flush=True) + + # Grab a screenshot. + screenshot = sct.grab(monitor) + frame_count += 1 + + # There are a few ways to get the screenshot into a VideoFrame. The highest-performance way isn't + # hard, and is shown in the full demo: search for from_numpy_buffer. But the most obvious way is to + # use PIL: you can create an Image from the screenshot, and create a VideoFrame from that. That said, + # if you want to boost the fps rate by about 50%, check out the full demo, and search for + # from_numpy_buffer. + img = Image.frombytes("RGB", screenshot.size, screenshot.bgra, "raw", "BGRX") + frame = av.VideoFrame.from_image(img) + + # When we encode frames, we get back a list of packets. Often, we'll get no packets at first: the + # video encoder wants to wait and see the motion before it decides how it wants to encode the frames. + # Later, once it's decided about the earlier frames, we'll start getting those packets, while it's + # holding on to later frames. + # + # You can imagine that the encoder is a factory. You're providing it frames, one at a time, each as a + # box of raw materials. It cranks out packets as its finished product. But there's some delay while + # it's working. You can imagine these on a conveyor belt moving left to right as time progresses: + # + # FRAMES ENCODER PACKETS + # [1]________-> (Factory) ->____________ + # [3]_[2]_[1]-> (Factory) ->____________ + # [6]_[5]_[4]-> (Factory) ->{1}_________ + # [8]_[7]_[6]-> (Factory) ->{3}_{2}_{1}_ + # + # Sometimes, when you send in a frame, you'll get no packets, sometimes you'll get one, and sometimes + # you'll get a batch of several. It depends on how the encoder works. + # + # The point is, the packets you're getting back from this call are whatever the encoder is ready to + # give you, not necessarily the packets related to the frame you're handing it right now. + packets = video_stream.encode(frame) + + # As we said, the MP4 file is a bunch of packets from possibly many streams, all woven (or "muxed") + # together. So the ultimate destination of the data is to send it to the MP4 file, avmux. + avmux.mux(packets) + + # Print an empty line to end our line of dots. + print() + + # Earlier, we mentioned that the encoder might hold onto some frames, while it decides how to encode them + # based on future frames. Now that we're done sending it frames, we need to get the packets for any + # frames it's still holding onto. This is referred to as "flushing" the stream. We do this by sending + # None instead of a frame object. + packets = video_stream.encode(None) + avmux.mux(packets) + + print(f"Capture complete: {frame_count / CAPTURE_SECONDS:.1f} fps") + + +if __name__ == "__main__": + main() diff --git a/demos/video-capture.py b/demos/video-capture.py new file mode 100755 index 00000000..cc89ca99 --- /dev/null +++ b/demos/video-capture.py @@ -0,0 +1,644 @@ +#! /usr/bin/env python3 + +# This demo shows one common use case for MSS: capture the screen and write a real video file (MP4) rather than saving +# individual images. +# +# It's intentionally not a full "video encoding" course. The goal is to explain the few concepts that show up +# throughout the program so you can read, tweak, and extend it. +# +# What tools are we using? +# ------------------------ +# +# You'll need a few libraries that don't come with Python: PyAV, NumPy, SI-Prefix, and (of course) MSS. You'll need +# to install those with "pip install av mss numpy si-prefix". Normally, you'll want to install these into a venv; if +# you don't know about those, there are lots of great tutorials online. The most critical one we use is PyAV. +# +# Most people first meet video encoding through the `ffmpeg` command. Under the hood, ffmpeg is built on the "libav*" +# C libraries. In this demo we use PyAV (`import av`), which is a Pythonic wrapper around those libraries. +# +# PyAV docs: +# Note: the older docs at pyav.org are outdated; see +# . +# Caveats: +# +# Containers, streams, and codecs +# ------------------------------- +# +# A file like `capture.mp4` is a *container*: it holds one or more *streams* (usually video and/or audio). This demo +# writes one video stream. +# +# The container interleaves ("muxes") stream data so players can read everything in timestamp order. libav calls those +# pieces "packets". (In MP4 they're not literally network-style packets; the term is a longstanding libav +# abstraction.) +# +# A *codec* is the algorithm that compresses/decompresses a stream. For MP4 video, common codecs include H.264 and +# H.265. This demo defaults to H.264 via `libx264`, because it's widely supported. You can switch to hardware +# encoders (e.g. `h264_nvenc`) if available. +# +# Frames and frame reordering (I/P/B) +# ---------------------------------- +# +# Video is encoded as a sequence of frames: +# - I-frames: complete images. +# - P-frames: changes from previous frames. +# - B-frames: changes predicted using both past *and future* frames. +# +# B-frames are why "the order frames are encoded/decoded" can differ from "the order frames are shown". That leads +# directly to timestamps. +# +# Timestamps (PTS/DTS) +# -------------------- +# +# Every frame has a *presentation timestamp* (PTS): when the viewer should see it. (See the next section for how +# these are represented.) +# +# Encoders may output packets in a different order due to B-frames. Those packets also have a *decode timestamp* +# (DTS): when the decoder must decode them so the PTS schedule can be met. +# +# In this demo we set PTS on `VideoFrame`s and let libav/PyAV propagate timestamps into the encoded packets. +# +# Time base +# --------- +# +# Timestamps are integers, and their unit is a fraction of a second called the *time base*. For example, with a time +# base of 1/90000, a timestamp of 90000 means "1 second". PyAV will convert between time bases when needed, but you +# must set them consistently where you generate timestamps. +# +# See +# +# This demo uses a time base of 1/90000 (a common MPEG-derived choice). +# +# Constant Frame Rate (CFR) and Variable Frame Rate (VFR) +# ------------------------------------------------------- +# +# Many video files run at a fixed frame rate, like 30 fps. Each frame is shown at 1/30 sec intervals. This is called +# *constant frame rate*, or *CFR*, and that's what we do in the simple version of this demo. +# +# Applications using CFR usually set the time base to the frame rate, such as 1/30 sec. This lets them just use the +# frame number for the PTS. +# +# One problem with real-time recording to CFR is that, if the encoder can't keep up, the video will appear sped-up +# when played back. The comments at the beginning of the simple version of this demo go into more detail about that +# problem. +# +# In this advanced version, we use *variable frame rate*, or *VFR*. That's because we can't be sure that the encoder +# will be able to work fast enough: we haven't tuned its settings for your screen resolution and hardware. While the +# encoder might be fast enough, it might only be able to operate at 18 fps, or even less. +# +# Instead, we mark each frame with the correct time that it should be shown. Even if the encoder is falling behind, +# its frames are still marked with the right times, so the player will just keep the previous frame on the screen a +# little longer. +# +# Some video editing software historically has had problems with VFR video. It's much better now than it was a few +# years ago, but if you plan to edit the video, you may need to convert it to CFR. There are many resources online +# about how to do that. +# +# Performance (why multiple threads?) +# ---------------------------------- +# +# Capturing frames, converting them to `VideoFrame`s, encoding, and muxing are separate stages. This demo pipelines +# those stages across threads so that (for example) encoding can run while the next screen grab is happening. The +# comments at the top of common/pipeline.py describe pipelining in detail. +# +# The slowest stage typically limits overall FPS. Usually, that's the encoder. +# +# On an idle system (rough guide; will vary widely): +# - libx264, 1920x1080: ~80 fps +# - libx264, 3840x2160: ~18 fps +# - h264_nvenc, 1920x1080: ~190 fps +# - h264_nvenc, 3840x2160: ~41 fps + +import argparse +import logging +import signal +import time +from collections import deque +from collections.abc import Generator, Iterable, Sequence +from fractions import Fraction +from functools import partial +from math import floor +from threading import Event +from typing import Any + +# Install the necessary libraries with "pip install av mss numpy si-prefix". +import av +import numpy as np +from si_prefix import si_format + +import mss +from common.pipeline import Mailbox, PipelineStage + + +# These are the options you'd give to ffmpeg that it sends to the video codec. Because ffmpeg and PyAV both use the +# libav libraries, you can get the list of available flags with `ffmpeg -help encoder=libx264`, or whatever encoder +# you're using for this demo's `--codec` flag. The options for each encoder are described in more detail in `man +# ffmpeg-codecs`. +CODEC_OPTIONS = { + # The "high" profile means that the encoder can use some H.264 features that are widely supported, but not + # mandatory. If you're using a codec other than H.264, you'll need to comment out this line: the relevant + # features are already part of the main profile in later codecs like H.265, VP8, VP9, and AV1. + "profile": "high", + # The "medium" preset is as good of a preset as any for a demo like this. Different codecs have different + # presets; the h264_nvenc actually prefers "p4", but accepts "medium" as a similar preset. You might prefer + # "fast" if you're not getting enough FPS. + "preset": "medium", + # 6 Mbit/sec is vaguely the ballpark for a good-quality video at 1080p and 30 fps, but there's a lot of variation. + # We're just giving the target bitrate: the second-to-second bitrate will vary a lot, and slowly approach this + # bitrate. If you're trying this on a nearly-still screen, though, then the actual bitrate will be much lower, + # since there's not much motion to encode! + "b": "6M", + # Let the encoder hold some frames for analysis, and flush them later. This especially helps with the + # hardware-accelerated codecs. + "rc-lookahead": "40", +} + + +TIME_BASE = Fraction(1, 90000) + +# Currently, MSS doesn't give us information about the display's colorspace. See where this is used below for more +# information. +DISPLAY_IS_SRGB = False + +LOGGER = logging.getLogger("video-capture") + + +def video_capture( + fps: int, + sct: mss.MSS, + monitor: mss.models.Monitor, + shutdown_requested: Event, +) -> Generator[tuple[mss.screenshot.ScreenShot, float], None, None]: + # Keep track of the time when we want to get the next frame. We limit the frame time this way instead of sleeping + # 1/fps sec each frame, since we want to also account for the time taken to get the screenshot and other overhead. + # + # Repeatedly adding small floating-point numbers to a total does cause some numeric inaccuracies, but it's small + # enough for our purposes. The program would have to run for three months to accumulate one millisecond of + # inaccuracy. + next_frame_at = time.monotonic() + + # Keep running this loop until the main thread says we should stop. + while not shutdown_requested.is_set(): + # Wait until we're ready. This should, ideally, happen every 1/fps second. + while (now := time.monotonic()) < next_frame_at: + time.sleep(next_frame_at - now) + + # Capture a frame, and send it to the next processing stage. + screenshot = sct.grab(monitor) + yield screenshot, now + + # We try to keep the capture rate at the desired fps on average. If we can't quite keep up for a moment (such + # as if the computer is a little overloaded), then we'll accumulate a bit of "timing debt" in next_frame_at: + # it'll be a little sooner than now + one frame. We'll hopefully be able to catch up soon. + next_frame_at = next_frame_at + (1 / fps) + + # If we've accumulated over one frame's worth of timing debt, then that will say that next_frame_at is sooner + # than now. If we're accumulating too much debt, we want to wipe it out, rather than having a huge burst of + # closely-spaced captures as soon as we can get back to our desired capture rate. When we wipe that out, we + # still try to preserve the timing cycle's phase to keep the capture cadence smooth, rather than having a + # jittery burst of closely-spaced captures. In other words, we increment next_frame_at by a multiple of the + # desired capture period. + if next_frame_at < now: + missed_frames = floor((now - next_frame_at) * fps) + next_frame_at += (missed_frames + 1) / fps + + +def video_process( + screenshot_and_timestamp: Iterable[tuple[mss.screenshot.ScreenShot, float]], +) -> Generator[av.VideoFrame, None, None]: + # We track when the first frame happened so we can make PTS start at 0. Many video players and other tools expect + # that. + first_frame_at: float | None = None + + for screenshot, timestamp in screenshot_and_timestamp: + # Avoiding extra pixel copies + # --------------------------- + # + # Copying a full frame of pixels is expensive. On typical hardware, a plain CPU memcpy of a 4K BGRA image can + # cost on the order of ~3ms by itself, which is a big chunk of a 30fps budget (33ms) and an even bigger chunk + # of a 60fps budget (16.7ms). + # + # So we want to be careful about the *conversion* step from an MSS `ScreenShot` to a PyAV `VideoFrame`. + # Ideally, that step should reuse the same underlying bytes rather than creating additional intermediate + # copies. + # + # Buffers in Python + # ----------------- + # + # Many Python objects expose their underlying memory via the "buffer protocol". A buffer is just a view of + # raw bytes that other libraries can interpret without copying. + # + # Common buffer objects include: `bytes`, `bytearray`, `memoryview`, and `array.array`. `screenshot.bgra` is + # also a buffer (currently it is a `bytes` object, though that detail may change in the future). + # + # Minimum-copy path: ScreenShot -> NumPy -> VideoFrame + # ---------------------------------------------------- + # + # `np.frombuffer()` creates an ndarray *view* of an existing buffer (no copy). Reshaping also stays as a + # view. + # + # PyAV's `VideoFrame.from_ndarray()` always copies the data into a new frame-owned buffer. For this demo we + # use the undocumented `VideoFrame.from_numpy_buffer()`, which creates a `VideoFrame` that shares memory with + # the ndarray. + ndarray = np.frombuffer(screenshot.bgra, dtype=np.uint8) + ndarray = ndarray.reshape(screenshot.height, screenshot.width, 4) + frame = av.VideoFrame.from_numpy_buffer(ndarray, format="bgra") + + # Set the PTS and time base for the frame. + # + # We compute PTS based on the actual time we captured the screenshot, relative to when we got the first + # frame. This gives us variable frame rate (VFR) video that accurately reflects the times the frames were + # captured. + # + # However, if we were muxing in an audio stream as well, we'd want to use a common clock for both audio and + # video PTS, preferably based on the audio clock. That's because audio glitches are more noticeable than + # video glitches, so audio timing should be prioritized. In that case, the video PTS would be based on the + # audio clock, not the actual capture time. + # + # The easiest way to do that is to record the monotonic clock in both the video and audio capture stages + # (taking the audio latency into account), record the audio PTS based on how many audio samples have been + # captured, and then adjust the video PTS based on the skew between the audio and monotonic clocks. + if first_frame_at is None: + first_frame_at = timestamp + frame.pts = int((timestamp - first_frame_at) / TIME_BASE) + frame.time_base = TIME_BASE + + # If we know the colorspace of our frames, mark them accordingly. See the comment where we set these + # attributes on video_stream for details. + if DISPLAY_IS_SRGB: + frame.colorspace = av.video.reformatter.Colorspace.ITU709 + frame.color_range = av.video.reformatter.ColorRange.JPEG + + yield frame + + +def video_encode( + video_stream: av.video.stream.VideoStream, frames: Iterable[av.VideoFrame] +) -> Generator[Sequence[av.Packet], None, None]: + for frame in frames: + yield video_stream.encode(frame) + # Our input has run out. Flush the frames that the encoder still is holding internally (such as to compute + # B-frames). + yield video_stream.encode(None) + + +def show_stats( + packet_batches: Iterable[Sequence[av.Packet]], +) -> Iterable[Sequence[av.Packet]]: + """Display streaming statistics (FPS and throughput). + + Statistics are displayed over a 100-frame sliding window. + + FPS indicates how fast the entire pipeline can run as a whole, not + any individual stage. + """ + # The start time is only used for showing the clock. The actual timing stats use packet timestamps (ultimately + # derived from the frame PTS we compute during capture). + start_time = time.monotonic() + time_deque: deque[int] = deque(maxlen=100) + bit_count_deque: deque[int] = deque(maxlen=100) + next_display_update = 0.0 + last_status_len = 0 + + for frame_count, packet_batch in enumerate(packet_batches): + # Yield the packet data immediately, so the mux gets it as soon as possible, while we update our stats. + yield packet_batch + + for packet in packet_batch: + # FPS from timestamps: why DTS, not PTS? + # + # Intuitively, you'd expect to compute FPS from PTS (the time the viewer should *see* each frame). But + # encoders can reorder frames internally (especially with B-frames), so packets may come out in a + # different order than PTS. + # + # If we update a sliding window with out-of-order PTS values, the window start/end can "wiggle" even when + # the pipeline is steady, which makes the displayed FPS noisy. + # + # DTS is the time order the decoder must process packets. Packets are emitted in DTS order, so using DTS + # gives a stable, monotonic timeline for the sliding window. + time_deque.append(packet.dts) + bit_count = packet.size * 8 + bit_count_deque.append(bit_count) + + now = time.monotonic() + if now >= next_display_update and len(time_deque) > 1: + next_display_update = now + 0.1 + running_time = now - start_time + running_minutes = int(running_time / 60) + running_seconds = int(running_time % 60) + window_secs = (time_deque[-1] - time_deque[0]) * TIME_BASE + # We can't use the last frame in the window when we divide by window_secs; that would be a fencepost + # error. + window_frames = len(time_deque) - 1 + window_bits = sum(bit_count_deque) - bit_count_deque[-1] + fps = window_frames / window_secs + bits_per_sec = int(window_bits / window_secs) + line = ( + f"{running_minutes:02d}:{running_seconds:02d} " + f"frame {frame_count}: {fps:.2f} fps, " + f"{si_format(bits_per_sec, precision=2)}bps" + ) + this_status_len = len(line) + full_line = f"\r{line}{' ' * (last_status_len - this_status_len)}" + print(full_line, end="") + last_status_len = this_status_len + + # At shutdown, the encoder flush can emit packets in large bursts, and we also throttle status updates (to avoid + # spamming the terminal). That combination means the last displayed line may be stale or not representative of + # the final frames. Rather than leaving potentially misleading numbers on screen, erase the status display. + print(f"\r{' ' * last_status_len}\r", end="") + + +def mux( + avmux: av.container.OutputContainer, + packet_batches: Iterable[Sequence[av.Packet]], +) -> None: + for packet_batch in packet_batches: + avmux.mux(packet_batch) + + +def parse_region(s: str) -> tuple[int, int, int, int]: + """Parse comma-separated region string into (left, top, right, bottom).""" + parts = s.split(",") + if len(parts) != 4: + msg = "region must be four comma-separated integers" + raise argparse.ArgumentTypeError(msg) + try: + return tuple(int(p.strip()) for p in parts) # type: ignore[return-value] + except ValueError as e: + msg = "region values must be integers" + raise argparse.ArgumentTypeError(msg) from e + + +def main() -> None: + logging.basicConfig(level=logging.DEBUG) + # If we don't enable PyAV's own logging, a lot of important error messages from libav won't be shown. + av.logging.set_level(av.logging.VERBOSE) + + parser = argparse.ArgumentParser(description="Capture screen video to MP4 file") + parser.add_argument( + "-f", + "--fps", + type=int, + default=30, + help="frames per second (default: 30)", + ) + monitor_group = parser.add_mutually_exclusive_group() + monitor_group.add_argument( + "-m", + "--monitor", + type=int, + default=1, + help="monitor ID to capture (default: 1)", + ) + monitor_group.add_argument( + "-r", + "--region", + type=parse_region, + metavar="LEFT,TOP,RIGHT,BOTTOM", + help="region to capture as comma-separated coordinates", + ) + parser.add_argument( + "-2", + "--region-crop-to-multiple-of-two", + action=argparse.BooleanOptionalAction, + help="crop the capture region to a multiple of two, as required by some codecs (default: only for libx264 and libx265)", + ) + parser.add_argument( + "-c", + "--codec", + default="libx264", + help=( + 'video codec implementation, same as the ffmpeg "-c:v" flag. ' + 'Run "python3 -m av --codecs" for a full list. ' + "(default: libx264. Try h264_nvenc for Nvidia " + "hardware encoding.)" + ), + ) + parser.add_argument( + "-d", + "--duration-secs", + type=float, + help="Duration to record (default: no limit)", + ) + parser.add_argument( + "-o", + "--output", + default="capture.mp4", + help="output filename (default: capture.mp4)", + ) + args = parser.parse_args() + + fps = args.fps + codec = args.codec + filename = args.output + duration_secs = args.duration_secs + region_crop_to_multiple_of_two = args.region_crop_to_multiple_of_two + + with mss.MSS() as sct: + if args.region: + left, top, right, bottom = args.region + monitor = { + "left": left, + "top": top, + "width": right - left, + "height": bottom - top, + } + else: + monitor = sct.monitors[args.monitor] + + # Some codecs, such as libx264, require the region to be a multiple of 2, to get the chroma subsampling right. + # Others, such as h264_nvenc, do not; they'll pad to get the subsampling region, and add flags to the stream + # to tell the viewer to crop accordingly. + if region_crop_to_multiple_of_two is None: + # The user didn't specify; choose the default. We haven't tested many codecs, but we know these require + # it (at least, when using 4:2:0 subsampling). + region_crop_to_multiple_of_two = codec in {"libx264", "libx265"} + if region_crop_to_multiple_of_two: + monitor["width"] = (monitor["width"] // 2) * 2 + monitor["height"] = (monitor["height"] // 2) * 2 + + # We don't pass the container format to av.open here, so it will choose it based on the extension: .mp4, .mkv, + # etc. + with av.open(filename, "w") as avmux: + # We could initialize video_stream in video_encode, but doing it here means that we can open it before + # starting the capture thread, which avoids a warmup frame (one that takes longer to encode because the + # encoder is just starting). + # + # The rate= parameter here is just the nominal frame rate: some tools (like file browsers) might display + # this as the frame rate. But we actually control timing via the pts and time_base values on the frames + # themselves. + video_stream = avmux.add_stream(codec, rate=fps, options=CODEC_OPTIONS) + + # Ideally, we would set attributes such as colorspace, color_range, color_primaries, and color_trc here to + # describe the colorspace accurately. Otherwise, the player has to guess whether this was recorded on an + # sRGB Windows machine, a Display P3 Mac, or if it's using linear RGB. Currently, MSS doesn't give us + # colorspace information (DISPLAY_IS_SRGB is always False in this demo), so we don't try to specify a + # particular colorspace. However, if your application knows the colorspace you're recording from, then + # you can set those attributes on the stream and the frames accordingly. + # + # These properties on the stream (actually, they're attached to its CodecContext) are used to tell the + # stream and container how to label the video stream's colorspace. There are similar attributes on the + # frame itself; those are used to identify its colorspace, so the codec can do the correct RGB to YUV + # conversion. + if DISPLAY_IS_SRGB: + # color_primaries=1 is libavutil's AVCOL_PRI_BT709; PyAV doesn't define named constants for color + # primaries. + video_stream.color_primaries = 1 + # What PyAV refers to as ITU709 is more commonly known as BT.709. + video_stream.colorspace = av.video.reformatter.Colorspace.ITU709 + # The "JPEG" color range is saying that we're using a color range like a computer, not like broadcast + # TV. + video_stream.color_range = av.video.reformatter.ColorRange.JPEG + # PyAV doesn't define named constants for TRCs, so we pass it a numeric value. Technically, sRGB's + # transformation characteristic is AVCOL_TRC_IEC61966_2_1 (13). It's nearly the same as BT.709's TRC, + # so some video encoders will tag it as AVCOL_TRC_BT709 (1) instead. + video_stream.color_trc = 13 + + video_stream.width = monitor["width"] + video_stream.height = monitor["height"] + # There are multiple time bases in play (stream, codec context, per-frame). Depending on the container + # and codec, some of these might be ignored or overridden. We set the desired time base consistently + # everywhere, so that the saved timestamps are correct regardless of what format we're saving to. + video_stream.time_base = TIME_BASE + video_stream.codec_context.time_base = TIME_BASE + # `pix_fmt` here describes the pixel format we will *feed* into the encoder (not necessarily what the + # encoder will store in the bitstream). H.264 encoders ultimately convert to a YUV 4:2:0 format + # internally. + # + # If the encoder accepts BGRx input (e.g., h264_nvenc), we can hand it MSS's BGRx frames directly and + # avoid an extra pre-conversion step on our side. For a hardware encoder, that lets specialized hardware + # do the conversion to YUV efficiently. + # + # If the encoder doesn't accept BGRx input (e.g., libx264), PyAV will insert a conversion step + # automatically. In that case, we let the codec choose the pix_fmt it wants. + # + # Note: the alpha channel is ignored by H.264. We usually are sending sending BGRx/BGR0. But PyAV's + # VideoFrame only exposes "bgra" as the closest supported format, so that's how we tag our frames, and + # what we tell the codec to expect, if possible. You might need to change this for codecs like VP9 that + # can handle alpha channels. + if any(f.name == "bgra" for f in video_stream.codec.video_formats): + video_stream.pix_fmt = "bgra" + # We open (initialize) the codec explicitly here. PyAV will automatically open it the first time we + # call video_stream.encode, but the time it takes to set the codec up means the first frame would be + # particularly slow. + video_stream.open() + + shutdown_requested = Event() + + mailbox_screenshot: Mailbox[tuple[mss.screenshot.ScreenShot, float]] = Mailbox() + mailbox_frame: Mailbox[av.VideoFrame] = Mailbox() + mailbox_packet_to_stats: Mailbox[Sequence[av.Packet]] = Mailbox() + mailbox_packet_to_mux: Mailbox[Sequence[av.Packet]] = Mailbox() + + stage_video_capture = PipelineStage( + name="video_capture", + target=partial( + video_capture, + fps, + sct, + monitor, + shutdown_requested, + ), + out_mailbox=mailbox_screenshot, + ) + stage_video_process = PipelineStage( + name="video_process", + in_mailbox=mailbox_screenshot, + target=partial(video_process), + out_mailbox=mailbox_frame, + ) + stage_video_encode = PipelineStage( + name="video_encode", + in_mailbox=mailbox_frame, + target=partial(video_encode, video_stream), + out_mailbox=mailbox_packet_to_stats, + ) + stage_show_stats = PipelineStage( + name="show_stats", + in_mailbox=mailbox_packet_to_stats, + target=show_stats, + out_mailbox=mailbox_packet_to_mux, + ) + stage_mux = PipelineStage( + name="stream_mux", + in_mailbox=mailbox_packet_to_mux, + target=partial(mux, avmux), + ) + + stage_mux.start() + stage_show_stats.start() + stage_video_process.start() + stage_video_encode.start() + stage_video_capture.start() + + LOGGER.debug("Native thread IDs:") + LOGGER.debug(" Capture: %s", stage_video_capture.native_id) + LOGGER.debug(" Preprocess: %s", stage_video_process.native_id) + LOGGER.debug(" Encode: %s", stage_video_encode.native_id) + LOGGER.debug(" Mux: %s", stage_mux.native_id) + + # Handle Ctrl-C gracefully by requesting shutdown. + # + # Python always routes signals to the main thread, so we don't have to worry about another thread getting + # a SIGINT (the Ctrl-C signal). That's significant because if the video capture stage tried to set the + # shutdown_requested event (which requires the event lock) while it was already waiting for it (hence + # holding the lock), it could end up deadlocked. The main thread doesn't ever acquire that lock. As + # another point of safety, Python only will invoke our signal handler at a "safe" point, such as between + # bytecode instructions. + + # We set old_sigint_handler twice: once here, and once when we change the handler. The first time is + # just in case a signal arrives in the tiny window between when we set the new handler (by calling + # signal.signal), and when we assign it to old_sigint_handler (with "="). Signal handling, like + # threading, is tricky to get right. + old_sigint_handler = signal.getsignal(signal.SIGINT) + + def sigint_handler(_signum: int, _frame: Any) -> None: + # Restore the default behavior, so if our shutdown doesn't work because of a bug in our code, the user + # can still press ^C again to terminate the program. (The default handler is also in + # signal.default_int_handler, but that's not documented.) + signal.signal(signal.SIGINT, old_sigint_handler) + # The status line will typically be visible, so start a fresh line for this message. + print("\nShutting down") + shutdown_requested.set() + + old_sigint_handler = signal.signal(signal.SIGINT, sigint_handler) + + print("Starting video capture. Press Ctrl-C to stop.") + + if duration_secs is not None: + # Wait for up to the specified duration. If the pipeline shuts down for other reasons (such as an + # exception), then we'll recognize it sooner with this join. + stage_video_capture.join(timeout=duration_secs) + # Either the join timed out, or we processed a ^C and requested it exit. Either way, it's safe to set + # the shutdown event again, and return to our normal processing loop. + shutdown_requested.set() + + stage_video_capture.join() + stage_video_process.join() + stage_video_encode.join() + stage_show_stats.join() + stage_mux.join() + + # PyAV may insert an implicit conversion step between the frames we provide and what the encoder actually + # accepts (pixel format, colorspace, etc.). When that happens, `video_stream.reformatter` gets set. + # + # This is useful to know for performance: those conversions are typically CPU-side work and can become a + # bottleneck. Hardware-accelerated encoders, such as `h264_nvenc`, often accept BGRx, and can perform the + # conversion using specialized hardware. + # + # We already know that libx264 doesn't accept RGB input, so we don't warn about that. (There is a + # libx264rgb, but that writes to a different H.264 format.) We just want to warn about other codecs, + # since some of them might have ways to use BGRx input, and the programmer might want to investigate. + # + # Note: `reformatter` is created lazily, so it may only be set after frames have been sent through the + # encoder, which is why we check it at the end. + if video_stream.reformatter is not None and codec != "libx264": + LOGGER.warning( + "PyAV inserted a CPU-side pixel-format/colorspace conversion step; this can reduce FPS. " + "Check the acceptable pix_fmts for this codec, and see if one of them can accept some " + "variation of BGRx input directly." + ) + + +if __name__ == "__main__": + main() diff --git a/docs/icon.png b/docs/icon.png new file mode 100644 index 00000000..ac153019 Binary files /dev/null and b/docs/icon.png differ diff --git a/docs/source/api.rst b/docs/source/api.rst index cef76dab..5d99fab7 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -2,379 +2,12 @@ MSS API ======= -Classes -======= - -macOS ------ - -.. module:: mss.darwin - -.. attribute:: CFUNCTIONS - - .. versionadded:: 6.1.0 - -.. function:: cgfloat - -.. class:: CGPoint - -.. class:: CGSize - -.. class:: CGRect - -.. class:: MSS - - .. attribute:: core - - .. attribute:: max_displays - -GNU/Linux ---------- - -.. module:: mss.linux - -.. attribute:: CFUNCTIONS - - .. versionadded:: 6.1.0 - -.. attribute:: PLAINMASK - -.. attribute:: ZPIXMAP - -.. class:: Display - - Structure that serves as the connection to the X server, and that contains all the information about that X server. - -.. class:: XErrorEvent - - XErrorEvent to debug eventual errors. - -.. class:: XFixesCursorImage - - Cursor structure - -.. class:: XImage - - Description of an image as it exists in the client's memory. - -.. class:: XRRCrtcInfo - - Structure that contains CRTC information. - -.. class:: XRRModeInfo - -.. class:: XRRScreenResources - - Structure that contains arrays of XIDs that point to the available outputs and associated CRTCs. - -.. class:: XWindowAttributes - - Attributes for the specified window. - -.. class:: MSS - - .. method:: close() - - Clean-up method. - - .. versionadded:: 8.0.0 - -Windows -------- - -.. module:: mss.windows - -.. attribute:: CAPTUREBLT - -.. attribute:: CFUNCTIONS - - .. versionadded:: 6.1.0 - -.. attribute:: DIB_RGB_COLORS - -.. attribute:: SRCCOPY - -.. class:: BITMAPINFOHEADER - -.. class:: BITMAPINFO - -.. attribute:: MONITORNUMPROC - - .. versionadded:: 6.1.0 - -.. class:: MSS - - .. attribute:: gdi32 - - .. attribute:: user32 - -Methods -======= - -.. module:: mss.base - -.. attribute:: lock - - .. versionadded:: 6.0.0 - -.. class:: MSSBase - - The parent's class for every OS implementation. - - .. attribute:: cls_image - - .. attribute:: compression_level - - PNG compression level used when saving the screenshot data into a file (see :py:func:`zlib.compress()` for details). - - .. versionadded:: 3.2.0 - - .. attribute:: with_cursor - - Include the mouse cursor in screenshots. - - .. versionadded:: 8.0.0 - - .. method:: __init__(compression_level=6, display=None, max_displays=32, with_cursor=False) - - :type compression_level: int - :param compression_level: PNG compression level. - :type display: bytes, str or None - :param display: The display to use. Only effective on GNU/Linux. - :type max_displays: int - :param max_displays: Maximum number of displays. Only effective on macOS. - :type with_cursor: bool - :param with_cursor: Include the mouse cursor in screenshots. - - .. versionadded:: 8.0.0 - ``compression_level``, ``display``, ``max_displays``, and ``with_cursor``, keyword arguments. - - .. method:: close() - - Clean-up method. - - .. versionadded:: 4.0.0 - - .. method:: grab(region) - - :param dict monitor: region's coordinates. - :rtype: :class:`ScreenShot` - - Retrieve screen pixels for a given *region*. - Subclasses need to implement this. - - .. note:: - - *monitor* can be a ``tuple`` like ``PIL.Image.grab()`` accepts, - it will be converted to the appropriate ``dict``. - - .. method:: save([mon=1], [output='mon-{mon}.png'], [callback=None]) - - :param int mon: the monitor's number. - :param str output: the output's file name. - :type callback: callable or None - :param callback: callback called before saving the screen shot to a file. Takes the *output* argument as parameter. - :rtype: iterable - :return: Created file(s). - - Grab a screen shot and save it to a file. - The *output* parameter can take several keywords to customize the filename: - - - ``{mon}``: the monitor number - - ``{top}``: the screen shot y-coordinate of the upper-left corner - - ``{left}``: the screen shot x-coordinate of the upper-left corner - - ``{width}``: the screen shot's width - - ``{height}``: the screen shot's height - - ``{date}``: the current date using the default formatter - - As it is using the :py:func:`format()` function, you can specify formatting options like ``{date:%Y-%m-%s}``. - - .. warning:: On Windows, the default date format may result with a filename containing ':' which is not allowed:: - - IOerror: [Errno 22] invalid mode ('wb') or filename: 'sct_1-2019-01-01 21:20:43.114194.png' - - To fix this, you must provide a custom date formatting. - - .. method:: shot() - - :return str: The created file. - - Helper to save the screen shot of the first monitor, by default. - You can pass the same arguments as for :meth:`save()`. - - .. versionadded:: 3.0.0 - -.. class:: ScreenShot - - Screen shot object. - - .. note:: - - A better name would have been *Image*, but to prevent collisions - with ``PIL.Image``, it has been decided to use *ScreenShot*. - - .. classmethod:: from_size(cls, data, width, height) - - :param bytearray data: raw BGRA pixels retrieved by ctypes - OS independent implementations. - :param int width: the monitor's width. - :param int height: the monitor's height. - :rtype: :class:`ScreenShot` - - Instantiate a new class given only screen shot's data and size. - - .. method:: pixel(coord_x, coord_y) - - :param int coord_x: The x coordinate. - :param int coord_y: The y coordinate. - :rtype: tuple(int, int, int) - - Get the pixel value at the given position. - - .. versionadded:: 3.0.0 - -.. module:: mss.tools - -.. method:: to_png(data, size, level=6, output=None) - - :param bytes data: RGBRGB...RGB data. - :param tuple size: The (width, height) pair. - :param int level: PNG compression level. - :param str output: output's file name. - :raises ScreenShotError: On error when writing *data* to *output*. - :raises zlib.error: On bad compression *level*. - - Dump data to the image file. Pure Python PNG implementation. - If *output* is ``None``, create no file but return the whole PNG data. - - .. versionadded:: 3.0.0 - - .. versionchanged:: 3.2.0 - - The *level* keyword argument to control the PNG compression level. - - -Properties -========== - -.. class:: mss.base.MSSBase - - .. attribute:: monitors - - Positions of all monitors. - If the monitor has rotation, you have to deal with it - inside this method. - - This method has to fill ``self._monitors`` with all information - and use it as a cache: - - - ``self._monitors[0]`` is a dict of all monitors together - - ``self._monitors[N]`` is a dict of the monitor N (with N > 0) - - Each monitor is a dict with: - - - ``left``: the x-coordinate of the upper-left corner - - ``top``: the y-coordinate of the upper-left corner - - ``width``: the width - - ``height``: the height - - Subclasses need to implement this. - - :rtype: list[dict[str, int]] - -.. class:: mss.base.ScreenShot - - .. attribute:: __array_interface__() - - Numpy array interface support. It uses raw data in BGRA form. - - :rtype: dict[str, Any] - - .. attribute:: bgra - - BGRA values from the BGRA raw pixels. - - :rtype: bytes - - .. versionadded:: 3.2.0 - - .. attribute:: height - - The screen shot's height. - - :rtype: int - - .. attribute:: left - - The screen shot's left coordinate. - - :rtype: int - - .. attribute:: pixels - - List of row tuples that contain RGB tuples. - - :rtype: list[tuple(tuple(int, int, int), ...)] - - .. attribute:: pos - - The screen shot's coordinates. - - :rtype: :py:func:`collections.namedtuple()` - - .. attribute:: rgb - - Computed RGB values from the BGRA raw pixels. - - :rtype: bytes - - .. versionadded:: 3.0.0 - - .. attribute:: size - - The screen shot's size. - - :rtype: :py:func:`collections.namedtuple()` - - .. attribute:: top - - The screen shot's top coordinate. - - :rtype: int - - .. attribute:: width - - The screen shot's width. - - :rtype: int - - -Exception -========= - -.. module:: mss.exception - -.. exception:: ScreenShotError - - Base class for MSS exceptions. - - .. attribute:: details - - On GNU/Linux, and if the error comes from the XServer, it contains XError details. - This is an empty dict by default. - - For XErrors, you can find information on `Using the Default Error Handlers `_. - - :rtype: dict[str, Any] - - .. versionadded:: 3.3.0 - - -Factory -======= +Core Package +============ -.. module:: mss.factory +.. automodule:: mss -.. function:: mss() +Data Models +=========== - Factory function to instance the appropriate MSS class. +.. automodule:: mss.models diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst new file mode 100644 index 00000000..9f448a2b --- /dev/null +++ b/docs/source/changelog.rst @@ -0,0 +1,2 @@ +.. include:: ../../CHANGELOG.md + :parser: myst_parser.sphinx_ diff --git a/docs/source/conf.py b/docs/source/conf.py index 20ae6344..11f3627c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,14 +6,34 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) +import ctypes + +# Monkey-patch PROT_READ into mmap if missing (Windows), so that we can +# import the Linux shared-memory backend implementation while building the +# documentation. +import mmap + +if not hasattr(mmap, "PROT_READ"): + mmap.PROT_READ = 1 + import mss # -- General configuration ------------------------------------------------ -extensions = ["sphinx.ext.intersphinx"] +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx_copybutton", + "sphinx.ext.intersphinx", + "sphinx_new_tab_link", +] templates_path = ["_templates"] -source_suffix = ".rst" +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} master_doc = "index" +new_tab_link_show_external_link_icon = True # General information about the project. project = "Python MSS" @@ -24,11 +44,37 @@ release = "latest" language = "en" todo_include_todos = True - +autodoc_member_order = "bysource" +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} +# Suppress duplicate target warnings for re-exported classes +suppress_warnings = ["ref.python"] + +# Monkey-patch WINFUNCTYPE and WinError into ctypes, so that we can +# import mss.windows while building the documentation. +ctypes.WINFUNCTYPE = ctypes.CFUNCTYPE # type:ignore[attr-defined] +ctypes.WinError = lambda _code=None, _descr=None: OSError() # type:ignore[attr-defined] # -- Options for HTML output ---------------------------------------------- -html_theme = "default" +html_theme = "shibuya" +html_theme_options = { + "accent_color": "lime", + "globaltoc_expand_depth": 1, + "toctree_titles_only": False, + "show_ai_links": False, +} +html_favicon = "../icon.png" +html_context = { + "source_type": "github", + "source_user": "BoboTiG", + "source_repo": "python-mss", + "source_docs_path": "/docs/source/", + "source_version": "main", +} htmlhelp_basename = "PythonMSSdoc" diff --git a/docs/source/developers.rst b/docs/source/developers.rst index d9c3e537..24b394e4 100644 --- a/docs/source/developers.rst +++ b/docs/source/developers.rst @@ -23,13 +23,13 @@ You will need `pytest `_:: $ python -m venv venv $ . venv/bin/activate $ python -m pip install -U pip - $ python -m pip install -e '.[test]' + $ python -m pip install -e '.[tests]' How to Test? ------------ -Launch the test suit:: +Launch the test suite:: $ python -m pytest @@ -40,7 +40,8 @@ Code Quality To ensure the code quality is correct enough:: $ python -m pip install -e '.[dev]' - $ ./check.sh + $ ./check.sh # Linux/macOS + $ .\check.ps1 # Windows (PowerShell) Documentation @@ -48,4 +49,16 @@ Documentation To build the documentation, simply type:: + $ python -m pip install -e '.[docs]' $ sphinx-build -d docs docs/source docs_out --color -W -bhtml + + +XCB Code Generator +================== + +.. versionadded:: 10.2.0 + +The GNU/Linux XCB backends rely on generated ctypes bindings. If you need to +add new XCB requests or types, do **not** edit ``src/mss/linux/xcbgen.py`` by +hand. Instead, follow the workflow described in ``src/xcbproto/README.md``, +which explains how to update ``gen_xcb_to_py.py`` and regenerate the bindings. diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 6adb7bc5..e10c6bb3 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -5,22 +5,22 @@ Examples Basics ====== -One screen shot per monitor ---------------------------- +One screenshot per monitor +-------------------------- :: for filename in sct.save(): print(filename) -Screen shot of the monitor 1 ----------------------------- +Screenshot of the monitor 1 +--------------------------- :: filename = sct.shot() print(filename) -A screen shot to grab them all ------------------------------- +A screenshot to grab them all +----------------------------- :: filename = sct.shot(mon=-1, output='fullscreen.png') @@ -29,10 +29,10 @@ A screen shot to grab them all Callback -------- -Screen shot of the monitor 1 with a callback: +Screenshot of the monitor 1 with a callback: .. literalinclude:: examples/callback.py - :lines: 8- + :lines: 7- Part of the screen @@ -41,7 +41,7 @@ Part of the screen You can capture only a part of the screen: .. literalinclude:: examples/part_of_screen.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -52,7 +52,7 @@ Part of the screen of the 2nd monitor This is an example of capturing some part of the screen of the monitor 2: .. literalinclude:: examples/part_of_screen_monitor_2.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -64,7 +64,7 @@ You can use the same value as you would do with ``PIL.ImageGrab(bbox=tuple(...)) This is an example that uses it, but also using percentage values: .. literalinclude:: examples/from_pil_tuple.py - :lines: 8- + :lines: 7- .. versionadded:: 3.1.0 @@ -83,7 +83,7 @@ Get PNG bytes, no file output You can get the bytes of the PNG image: :: - with mss.mss() as sct: + with mss.MSS() as sct: # The monitor or screen part to capture monitor = sct.monitors[1] # or a region @@ -96,13 +96,29 @@ You can get the bytes of the PNG image: Advanced ======== +.. _custom_cls_image: + +Custom ScreenShot Subclass +-------------------------- + You can handle data using a custom class: .. literalinclude:: examples/custom_cls_image.py - :lines: 8- + :lines: 7- .. versionadded:: 3.1.0 +GNU/Linux XShm backend +---------------------- + +Select the XShmGetImage backend explicitly and inspect whether it is active or +falling back to XGetImage: + +.. literalinclude:: examples/linux_xshm_backend.py + :lines: 7- + +.. versionadded:: 10.2.0 + PIL === @@ -110,7 +126,7 @@ You can use the Python Image Library (aka Pillow) to do whatever you want with r This is an example using `frombytes() `_: .. literalinclude:: examples/pil.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -120,7 +136,7 @@ Playing with pixels This is an example using `putdata() `_: .. literalinclude:: examples/pil_pixels.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -132,7 +148,7 @@ You can easily view a HD movie with VLC and see it too in the OpenCV window. And with __no__ lag please. .. literalinclude:: examples/opencv_numpy.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -145,7 +161,7 @@ Benchmark Simple naive benchmark to compare with `Reading game frames in Python with OpenCV - Python Plays GTA V `_: .. literalinclude:: examples/fps.py - :lines: 9- + :lines: 8- .. versionadded:: 3.0.0 @@ -156,7 +172,7 @@ Performances can be improved by delegating the PNG file creation to a specific w This is a simple example using the :py:mod:`multiprocessing` inspired by the `TensorFlow Object Detection Introduction `_ project: .. literalinclude:: examples/fps_multiprocessing.py - :lines: 9- + :lines: 8- .. versionadded:: 5.0.0 @@ -187,9 +203,27 @@ Different possibilities to convert raw BGRA values to RGB:: return Image.frombytes('RGB', im.size, im.bgra, 'raw', 'BGRX').tobytes() - with mss.mss() as sct: + with mss.MSS() as sct: im = sct.grab(sct.monitors[1]) rgb = pil_frombytes(im) ... .. versionadded:: 3.2.0 + + +Demos +===== + +In addition to these simple examples, there are full demos of more complex use cases in the `demos `_ directory of the +source code. The demos are not installed with the package, but you can run them directly from the source tree after +cloning the repository. + +These are complete, working programs that use MSS for screen capture as a key part of their functionality. They show +not only how to invoke MSS, but also some of the techniques for using the captured frames efficiently, in real-world +scenarios. + +These include: + +- MP4 video capture with encoding using PyAV (FFmpeg bindings) +- Live streaming to a TinyTV as MJPEG +- Detect images of cats on the screen diff --git a/docs/source/examples/callback.py b/docs/source/examples/callback.py index a1071762..6355766a 100644 --- a/docs/source/examples/callback.py +++ b/docs/source/examples/callback.py @@ -3,20 +3,21 @@ 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: +with mss.MSS() as sct: filename = sct.shot(output="mon-{mon}.png", callback=on_exists) print(filename) diff --git a/docs/source/examples/custom_cls_image.py b/docs/source/examples/custom_cls_image.py index c57e1113..2c04a150 100644 --- a/docs/source/examples/custom_cls_image.py +++ b/docs/source/examples/custom_cls_image.py @@ -3,6 +3,7 @@ Screenshot of the monitor 1, using a custom class to handle the data. """ + from typing import Any import mss @@ -11,7 +12,7 @@ class SimpleScreenShot(ScreenShot): - """Define your own custom method to deal with screen shot raw data. + """Define your own custom method to deal with screenshot raw data. Of course, you can inherit from the ScreenShot class and change or add new methods. """ @@ -21,7 +22,7 @@ def __init__(self, data: bytearray, monitor: Monitor, **_: Any) -> None: self.monitor = monitor -with mss.mss() as sct: +with mss.MSS() as sct: sct.cls_image = SimpleScreenShot image = sct.grab(sct.monitors[1]) # ... diff --git a/docs/source/examples/fps.py b/docs/source/examples/fps.py index 7a338439..dde93cd8 100644 --- a/docs/source/examples/fps.py +++ b/docs/source/examples/fps.py @@ -4,19 +4,17 @@ Simple naive benchmark to compare with: https://pythonprogramming.net/game-frames-open-cv-python-plays-gta-v/ """ + import time import cv2 -import mss import numpy as np +from PIL import ImageGrab +import mss -def screen_record() -> int: - try: - from PIL import ImageGrab - except ImportError: - return 0 +def screen_record() -> int: # 800x600 windowed mode mon = (0, 40, 800, 640) @@ -42,7 +40,7 @@ def screen_record_efficient() -> int: title = "[MSS] FPS benchmark" fps = 0 - sct = mss.mss() + sct = mss.MSS() last_time = time.time() while time.time() - last_time < 1: diff --git a/docs/source/examples/fps_multiprocessing.py b/docs/source/examples/fps_multiprocessing.py index a54ac3e8..ea28dad7 100644 --- a/docs/source/examples/fps_multiprocessing.py +++ b/docs/source/examples/fps_multiprocessing.py @@ -4,6 +4,7 @@ Example using the multiprocessing module to speed-up screen capture. https://github.com/pythonlessons/TensorFlow-object-detection-tutorial """ + from multiprocessing import Process, Queue import mss @@ -13,7 +14,7 @@ def grab(queue: Queue) -> None: rect = {"top": 0, "left": 0, "width": 600, "height": 800} - with mss.mss() as sct: + with mss.MSS() as sct: for _ in range(1_000): queue.put(sct.grab(rect)) @@ -39,6 +40,6 @@ def save(queue: Queue) -> None: # The screenshots queue queue: Queue = Queue() - # 2 processes: one for grabing and one for saving PNG files + # 2 processes: one for grabbing and one for saving PNG files Process(target=grab, args=(queue,)).start() Process(target=save, args=(queue,)).start() diff --git a/docs/source/examples/from_pil_tuple.py b/docs/source/examples/from_pil_tuple.py index c5ed5f44..aa056109 100644 --- a/docs/source/examples/from_pil_tuple.py +++ b/docs/source/examples/from_pil_tuple.py @@ -3,10 +3,11 @@ Use PIL bbox style and percent values. """ + import mss import mss.tools -with mss.mss() as sct: +with mss.MSS() as sct: # Use the 1st monitor monitor = sct.monitors[1] diff --git a/docs/source/examples/linux_display_keyword.py b/docs/source/examples/linux_display_keyword.py index 2070aea3..6e68c799 100644 --- a/docs/source/examples/linux_display_keyword.py +++ b/docs/source/examples/linux_display_keyword.py @@ -3,8 +3,9 @@ Usage example with a specific display. """ + import mss -with mss.mss(display=":0.0") as sct: +with mss.MSS(display=":0.0") as sct: for filename in sct.save(): print(filename) diff --git a/docs/source/examples/linux_xshm_backend.py b/docs/source/examples/linux_xshm_backend.py new file mode 100644 index 00000000..c3691615 --- /dev/null +++ b/docs/source/examples/linux_xshm_backend.py @@ -0,0 +1,15 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. + +Select the XShmGetImage backend explicitly and inspect its status. +""" + +from mss import MSS + +with MSS(backend="xshmgetimage") as sct: + screenshot = sct.grab(sct.monitors[1]) + print(f"Captured screenshot dimensions: {screenshot.size.width}x{screenshot.size.height}") + + print("Did MIT-SHM work:") + for message in sct.performance_status: + print(message) diff --git a/docs/source/examples/opencv_numpy.py b/docs/source/examples/opencv_numpy.py index 94bdbc39..1eb51fd5 100644 --- a/docs/source/examples/opencv_numpy.py +++ b/docs/source/examples/opencv_numpy.py @@ -3,13 +3,15 @@ OpenCV/Numpy example. """ + import time import cv2 -import mss import numpy as np -with mss.mss() as sct: +import mss + +with mss.MSS() as sct: # Part of the screen to capture monitor = {"top": 40, "left": 0, "width": 800, "height": 640} diff --git a/docs/source/examples/part_of_screen.py b/docs/source/examples/part_of_screen.py index bcc17bb3..2cc29841 100644 --- a/docs/source/examples/part_of_screen.py +++ b/docs/source/examples/part_of_screen.py @@ -3,10 +3,11 @@ Example to capture part of the screen. """ + import mss import mss.tools -with mss.mss() as sct: +with mss.MSS() as sct: # The screen part to capture monitor = {"top": 160, "left": 160, "width": 160, "height": 135} output = "sct-{top}x{left}_{width}x{height}.png".format(**monitor) diff --git a/docs/source/examples/part_of_screen_monitor_2.py b/docs/source/examples/part_of_screen_monitor_2.py index 56bfbdc3..082a56f6 100644 --- a/docs/source/examples/part_of_screen_monitor_2.py +++ b/docs/source/examples/part_of_screen_monitor_2.py @@ -3,10 +3,11 @@ Example to capture part of the screen of the monitor 2. """ + import mss import mss.tools -with mss.mss() as sct: +with mss.MSS() as sct: # Get information of monitor 2 monitor_number = 2 mon = sct.monitors[monitor_number] diff --git a/docs/source/examples/pil.py b/docs/source/examples/pil.py index 01a6b01d..dfb706b4 100644 --- a/docs/source/examples/pil.py +++ b/docs/source/examples/pil.py @@ -3,10 +3,12 @@ PIL example using frombytes(). """ -import mss + from PIL import Image -with mss.mss() as sct: +import mss + +with mss.MSS() as sct: # Get rid of the first, as it represents the "All in One" monitor: for num, monitor in enumerate(sct.monitors[1:], 1): # Get raw pixels from the screen diff --git a/docs/source/examples/pil_pixels.py b/docs/source/examples/pil_pixels.py index 54c57227..fdb1f673 100644 --- a/docs/source/examples/pil_pixels.py +++ b/docs/source/examples/pil_pixels.py @@ -3,10 +3,12 @@ PIL examples to play with pixels. """ -import mss + from PIL import Image -with mss.mss() as sct: +import mss + +with mss.MSS() as sct: # Get a screenshot of the 1st monitor sct_img = sct.grab(sct.monitors[1]) diff --git a/docs/source/index.rst b/docs/source/index.rst index c42c6712..cf0469fd 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,27 +1,35 @@ Welcome to Python MSS's documentation! ====================================== +|PyPI Version| +|PyPI Status| +|PyPI Python Versions| +|GitHub Build Status| +|GitHub License| + +|Patreon| + .. code-block:: python - from mss import mss + from mss import MSS - # The simplest use, save a screen shot of the 1st monitor - with mss() as sct: + # The simplest use, save a screenshot of the 1st monitor + with MSS() as sct: sct.shot() An ultra fast cross-platform multiple screenshots module in pure python using ctypes. - - **Python 3.8+**, :pep:`8` compliant, no dependency, thread-safe; - - very basic, it will grab one screen shot by monitor or a screen shot of all monitors and save it to a PNG file; + - **Python 3.9+**, :pep:`8` compliant, no dependency, thread-safe; + - very basic, it will grab one screenshot by monitor or a screenshot of all monitors and save it to a PNG file; - but you can use PIL and benefit from all its formats (or add yours directly); - integrate well with Numpy and OpenCV; - - it could be easily embedded into games and other software which require fast and platform optimized methods to grab screen shots (like AI, Computer Vision); + - it could be easily embedded into games and other software which require fast and platform optimized methods to grab screenshots (like AI, Computer Vision); - get the `source code on GitHub `_; - learn with a `bunch of examples `_; - you can `report a bug `_; - - need some help? Use the tag *python-mss* on `StackOverflow `_; - - **MSS** stands for Multiple Screen Shots; + - need some help? Use the tag *python-mss* on `Stack Overflow `_; + - **MSS** stands for Multiple ScreenShots; +-------------------------+ | Content | @@ -35,6 +43,8 @@ An ultra fast cross-platform multiple screenshots module in pure python using ct | support | | api | | developers | +| versioning | +| changelog | | where | +-------------------------+ @@ -43,3 +53,16 @@ Indices and tables * :ref:`genindex` * :ref:`search` + +.. |PyPI Version| image:: https://img.shields.io/pypi/v/mss.svg + :target: https://pypi.python.org/pypi/mss/ +.. |PyPI Status| image:: https://img.shields.io/pypi/status/mss.svg + :target: https://pypi.python.org/pypi/mss/ +.. |PyPI Python Versions| image:: https://img.shields.io/pypi/pyversions/mss.svg + :target: https://pypi.python.org/pypi/mss/ +.. |Github Build Status| image:: https://github.com/BoboTiG/python-mss/actions/workflows/tests.yml/badge.svg?branch=main + :target: https://github.com/BoboTiG/python-mss/actions/workflows/tests.yml +.. |GitHub License| image:: https://img.shields.io/github/license/BoboTiG/python-mss.svg + :target: https://github.com/BoboTiG/python-mss/blob/main/LICENSE.txt +.. |Patreon| image:: https://img.shields.io/badge/Patreon-F96854?style=for-the-badge&logo=patreon&logoColor=white + :target: https://www.patreon.com/mschoentgen diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 0dae108e..d003f790 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -14,7 +14,7 @@ Quite simple:: Conda Package ------------- -The module is also available from conda:: +The module is also available from Conda:: $ conda install -c conda-forge python-mss diff --git a/docs/source/support.rst b/docs/source/support.rst index 8a11d6b6..c0e4effb 100644 --- a/docs/source/support.rst +++ b/docs/source/support.rst @@ -4,8 +4,8 @@ Support Feel free to try MSS on a system we had not tested, and let us know by creating an `issue `_. - - OS: GNU/Linux, macOS and Windows - - Python: 3.8 and newer + - OS: GNU/Linux, macOS, and Windows + - Python: 3.9 and newer Future @@ -34,3 +34,4 @@ Abandoned - Python 3.5 (2022-10-27) - Python 3.6 (2022-10-27) - Python 3.7 (2023-04-09) +- Python 3.8 (2024-11-14) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 47bf7cbe..b01bfee9 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -5,29 +5,33 @@ Usage Import ====== -So MSS can be used as simply as:: +MSS can be used simply as:: - from mss import mss + from mss import MSS -Or import the good one based on your operating system:: + with MSS() as sct: + # ... - # MacOS X - from mss.darwin import MSS as mss +For compatibility with existing code, :py:func:`mss.mss` is still available in +10.2, but deprecated:: - # GNU/Linux - from mss.linux import MSS as mss + import mss - # Microsoft Windows - from mss.windows import MSS as mss + with mss.mss() as sct: # Deprecated in 10.2 + # ... +For compatibility with existing code, platform-specific class names are also +still available in 10.2:: -Instance -======== + # GNU/Linux + from mss.linux import MSS -So the module can be used as simply as:: + # macOS + from mss.darwin import MSS + + # Microsoft Windows + from mss.windows import MSS - with mss() as sct: - # ... Intensive Use ============= @@ -37,30 +41,90 @@ If you plan to integrate MSS inside your own module or software, pay attention t This is a bad usage:: for _ in range(100): - with mss() as sct: + with MSS() as sct: sct.shot() This is a much better usage, memory efficient:: - with mss() as sct: + with MSS() as sct: for _ in range(100): sct.shot() Also, it is a good thing to save the MSS instance inside an attribute of your class and calling it when needed. +Multithreading +============== + +MSS is thread-safe and can be used from multiple threads. + +**Sharing one MSS object**: You can use the same MSS object from multiple threads. Calls to +:py:meth:`mss.MSS.grab` (and other capture methods) are serialized automatically, meaning only one thread +will capture at a time. This may be relaxed in a future version if it can be done safely. + +**Using separate MSS objects**: You can also create different MSS objects in different threads. Whether these run +concurrently or are serialized by the OS depends on the platform. + +Custom :py:class:`mss.screenshot.ScreenShot` classes (see :ref:`custom_cls_image`) must **not** call +:py:meth:`mss.MSS.grab` in their constructor. + +.. danger:: + These guarantees do not apply to the obsolete Xlib backend. That backend + is only used if you specifically request it, so you won't be caught + off-guard. + +.. versionadded:: 10.2.0 + Prior to this version, on some operating systems, the MSS object could only be used on the thread on which it was + created. + +.. _backends: + +Backends +======== + +Some platforms have multiple ways to take screenshots. In MSS, these are known as *backends*. The :py:class:`mss.MSS` +constructor will normally autodetect which one is appropriate for your situation, but you can override this if you want. +For instance, you may know that your specific situation requires a particular backend. + +If you want to choose a particular backend, you can pass the ``backend`` keyword to :py:class:`mss.MSS`:: + + with MSS(backend="xgetimage") as sct: + ... + +Currently, only the GNU/Linux implementation has multiple backends. These are described in their own section below. + + GNU/Linux --------- -On GNU/Linux, you can specify which display to use (useful for distant screenshots via SSH):: - - with mss(display=":0.0") as sct: - # ... +Display +^^^^^^^ -A more specific example (only valid on GNU/Linux): +On GNU/Linux, the default display is taken from the :envvar:`DISPLAY` environment variable. You can instead specify which display to use (useful for distant screenshots via SSH) using the ``display`` keyword: .. literalinclude:: examples/linux_display_keyword.py - :lines: 8- + :lines: 7- + + +Backends +^^^^^^^^ + +The GNU/Linux implementation has multiple backends (see :ref:`backends`), or ways it can take screenshots. The :py:class:`mss.MSS` constructor will normally autodetect which one is appropriate, but you can override this if you want. + +There are three available backends. + +:py:mod:`xshmgetimage` (default) + The fastest backend, based on :c:func:`xcb_shm_get_image`. It is roughly three times faster than :py:mod:`xgetimage` + and is used automatically. When the MIT-SHM extension is unavailable (for example on remote SSH displays), it + transparently falls back to :py:mod:`xgetimage` so you can always request it safely. + +:py:mod:`xgetimage` + A highly-compatible, but slower, backend based on :c:func:`xcb_get_image`. Use this explicitly only when you know + that :py:mod:`xshmgetimage` cannot operate in your environment. + +:py:mod:`xlib` + The legacy backend powered by :c:func:`XGetImage`. It is kept solely for systems where XCB libraries are + unavailable and no new features are being added to it. Command Line @@ -73,8 +137,8 @@ You can use ``mss`` via the CLI:: Or via direct call from Python:: $ python -m mss --help - usage: __main__.py [-h] [-c COORDINATES] [-l {0,1,2,3,4,5,6,7,8,9}] - [-m MONITOR] [-o OUTPUT] [-q] [-v] [--with-cursor] + usage: mss [-h] [-c COORDINATES] [-l {0,1,2,3,4,5,6,7,8,9}] [-m MONITOR] + [-o OUTPUT] [--with-cursor] [-q] [-b BACKEND] [-v] options: -h, --help show this help message and exit @@ -83,9 +147,12 @@ Or via direct call from Python:: -l {0,1,2,3,4,5,6,7,8,9}, --level {0,1,2,3,4,5,6,7,8,9} the PNG compression level -m MONITOR, --monitor MONITOR - the monitor to screen shot + the monitor to screenshot -o OUTPUT, --output OUTPUT the output file name + -b, --backend BACKEND + platform-specific backend to use + (Linux: default/xlib/xgetimage/xshmgetimage; macOS/Windows: default) --with-cursor include the cursor -q, --quiet do not print created files -v, --version show program's version number and exit @@ -94,3 +161,6 @@ Or via direct call from Python:: .. versionadded:: 8.0.0 ``--with-cursor`` to include the cursor in screenshots. + +.. versionadded:: 10.2.0 + ``--backend`` to force selecting the backend to use. diff --git a/docs/source/versioning.rst b/docs/source/versioning.rst new file mode 100644 index 00000000..c15e57d9 --- /dev/null +++ b/docs/source/versioning.rst @@ -0,0 +1,198 @@ +Versioning +========== + +This document describes how changes are managed across MSS releases and +what users can expect when upgrading. + +MSS follows `Semantic Versioning `_ (SemVer) with +additional conventions described below. + +These guidelines describe how changes are managed and reflect the +project's intent. They are not a guarantee of behavior in all cases. + +Overview +-------- + +MSS version numbers follow the format :samp:`{major}.{minor}.{patch}`: + +- **Major versions** introduce backward-incompatible changes. +- **Minor versions** add new features or improvements without breaking + existing documented usage. +- **Patch versions** fix bugs and do not intentionally change the public + API. + +Patch and minor releases are intended to be backward-compatible with +previous releases of the same major version. If a regression occurs, it +is treated as a defect. + +Public API +---------- + +The public API consists of: + +- Features documented in the official documentation (Sphinx docs built + from :file:`docs/`), unless explicitly marked otherwise +- Features demonstrated in official examples (:file:`docs/source/examples/`) + or demos (:file:`demos/`), unless explicitly marked otherwise + +Examples and demos are intended to show recommended usage patterns and +are considered part of the public surface that users may reasonably rely +on. + +The following are **not** considered part of the public API: + +- Undocumented symbols +- Internal modules or backend-specific implementation details +- Docstrings (which may reference internal behavior and are not yet + fully audited) + +Some currently accessible symbols may still be internal even if not +prefixed with :code:`_`. These should not be relied upon and may change +without notice. + +Compatibility Rules +------------------- + +The following describes how changes are generally handled across +versions. + +Changes That Require a Major Version +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following changes are treated as backward-incompatible: + +- Removing public API symbols (functions, classes, attributes, etc.) +- Removing keyword parameters +- Making function arguments more restrictive than documented +- Returning values outside documented types +- Raising exceptions in cases where behavior was previously documented + or clearly implied to succeed +- Removing support for Python or operating system versions + +Changes That Do Not Require a Major Version +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following changes are considered backward-compatible: + +- Adding new optional or keyword parameters +- Adding new functions, attributes, or data fields +- Widening accepted parameter types +- Narrowing return types within documented bounds +- Raising exceptions for previously undefined or invalid inputs +- Emitting or modifying warnings +- Returning subclasses where a base class was previously returned +- Changing exception messages (exception types remain stable) + +Deprecation Policy +------------------ + +When a feature is planned for removal: + +- It is typically deprecated in a minor release before removal in a + future major release +- Deprecation notices are included in the documentation and release + notes +- :py:class:`DeprecationWarning` may be emitted where practical + +In some cases, deprecated features may be removed from documentation +before being removed from the code. + +Feature Gating +-------------- + +New functionality may be introduced behind explicit user opt-in +mechanisms ("feature gates"). + +Behavior enabled only through an explicit opt-in is not considered a +breaking change. + +Hypothetical example of a gated feature:: + + with MSS() as sct: + img = sct.grab(sct.primary_monitor) + # returns ScreenShotCPU + + with MSS(device="cuda") as sct: + img = sct.grab(sct.primary_monitor) + # returns ScreenShotCUDA + +Because the new behavior is only enabled when explicitly requested, it +does not affect existing usage. + +Typing and Compatibility +------------------------ + +Type annotations may evolve across major versions. + +In some cases, type changes may occur that do not affect runtime +behavior but may require updates for static type checking tools. + +When evaluating such changes, considerations include: + +- Likelihood of affecting real-world usage +- Difficulty of adapting existing code +- Overall benefit to the ecosystem + +Runtime compatibility is generally prioritized over strict type +stability. + +In some limited cases, MSS may widen type annotations in a minor +release to support a new feature that is only available through +explicit user opt-in. This is only considered for gated features where +the runtime behavior of existing code does not change and where +type-checking support is added so that static analysis can still infer +the narrower type in ordinary usage. + +For example, this may be appropriate when overloads, generics, or other +typing features allow type checkers to determine the correct return type +based on the user's explicit configuration. MSS may use this approach +when it is expected to avoid type-checking impact for the vast majority +of users and when the added feature is important enough to justify the +change. + +Stability Guidelines +-------------------- + +MSS aims to preserve documented behavior across releases. This includes +the meaning of documented APIs, arguments, return values, and data +fields. + +Behavior that is undocumented, incidental, or implementation-specific +should not be relied upon and may change between releases. + +Internal strategies, backend selection, validation details, error +messages, and other implementation details are not considered stable +unless explicitly documented. + +Widely used features receive greater stability consideration than niche +or specialized functionality. + +Writing Forward-Compatible Code +------------------------------- + +To minimize disruption when upgrading: + +- Use documented public APIs only +- Avoid relying on internal modules or backend-specific behavior +- Prefer explicit, documented interfaces over implicit conventions +- Expect stricter validation of inputs over time + +Undocumented behavior should not be relied upon and may change without +notice. + +Philosophy +---------- + +MSS aims to be: + +- Easy to use for programmers of all experience levels +- Suitable for a wide range of projects + +Changes are made carefully, with the goal of improving functionality, +performance, and maintainability while minimizing disruption. + +When breaking changes are necessary, they are introduced deliberately +and with advance notice where practical. + +Where possible, compatibility layers may be provided to allow existing +code to continue working during transitions. diff --git a/docs/source/where.rst b/docs/source/where.rst index 95acc6f1..fccc6527 100644 --- a/docs/source/where.rst +++ b/docs/source/where.rst @@ -2,12 +2,14 @@ Who Uses it? ============ -This is a non exhaustive list where MSS is integrated or has inspired. -Do not hesistate to `say Hello! `_ if you are using MSS too. +This is a non `exhaustive list `_ where MSS is integrated or has inspired. +Do not hesitate to `say Hello! `_ if you are using MSS too. +- Nvidia; - `Airtest `_, a cross-platform UI automation framework for aames and apps; - `Automation Framework `_, a Batmans utility; - `DeepEye `_, a deep vision-based software library for autonomous and advanced driver-assistance systems; +- `Diablo 4 Loot Filter `_; - `DoomPy `_ (Autonomous Anti-Demonic Combat Algorithms); - `Europilot `_, a self-driving algorithm using Euro Truck Simulator (ETS2); - `Flexx Python UI toolkit `_; @@ -24,6 +26,7 @@ Do not hesistate to `say Hello! `_ - `Python-ImageSearch `_, a wrapper around OpenCV2 and PyAutoGUI to do image searching easily; - `PUBGIS `_, a map generator of your position throughout PUBG gameplay; - `ScreenCapLibrary `_, a Robot Framework test library for capturing screenshots and video recording; +- `ScreenVivid `_, an open source cross-platform screen recorder for everyone ; - `Self-Driving-Car-3D-Simulator-With-CNN `_; - `Serpent.AI `_, a Game Agent Framework; - `Star Wars - The Old Republic: Galactic StarFighter `_ parser; diff --git a/pyproject.toml b/pyproject.toml index 5a0d1252..c94265ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "mss" description = "An ultra fast cross-platform multiple screenshots module in pure python using ctypes." readme = "README.md" -requires-python = ">= 3.8" +requires-python = ">= 3.9" authors = [ { name = "Mickaël Schoentgen", email="contact@tiger-222.fr" }, ] @@ -31,11 +31,12 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Multimedia :: Graphics :: Capture :: Screen Capture", "Topic :: Software Development :: Libraries", ] @@ -69,21 +70,29 @@ Tracker = "https://github.com/BoboTiG/python-mss/issues" mss = "mss.__main__:main" [project.optional-dependencies] -test = [ - "numpy", - "pillow", - "pytest", - "pytest-cov", - "pytest-rerunfailures", - "pyvirtualdisplay; sys_platform == 'linux'", - "sphinx", -] dev = [ - "build", - "mypy", - "ruff", - "twine", - "wheel", + "build==1.4.2", + "lxml==6.0.2", + "mypy==1.19.1", + "ruff==0.15.9", + "twine==6.2.0", +] +docs = [ + "myst-parser==5.0.0", + "shibuya==2026.1.9", + "sphinx==9.1.0", + "sphinx-copybutton==0.5.2", + "sphinx-new-tab-link==0.8.1", +] +tests = [ + "numpy==2.4.3 ; sys_platform == 'linux' and python_version == '3.13'", + "pillow==12.1.1 ; sys_platform == 'linux' and python_version == '3.13'", + "pytest==9.0.2 ; python_version > '3.9'", + "pytest==8.4.2 ; python_version == '3.9'", + "pytest-cov==7.1.0", + "pytest-rerunfailures==16.1 ; python_version > '3.9'", + "pytest-rerunfailures==16.0.1 ; python_version == '3.9'", + "pyvirtualdisplay==3.0 ; sys_platform == 'linux'", ] [tool.hatch.version] @@ -106,6 +115,13 @@ packages = [ "src/mss", ] +[tool.coverage.report] +exclude_also = [ + "except ImportError:", + "if TYPE_CHECKING:", + 'if __name__ == "__main__":', +] + [tool.mypy] # Ensure we know what we do warn_redundant_casts = true @@ -129,13 +145,14 @@ strict_equality = true [tool.pytest.ini_options] pythonpath = "src" +markers = ["without_libraries"] addopts = """ --showlocals --strict-markers -r fE - -vvv + -v --cov=src/mss - --cov-report=term-missing + --cov-report=term-missing:skip-covered """ [tool.ruff] @@ -148,32 +165,42 @@ exclude = [ ] line-length = 120 indent-width = 4 -target-version = "py38" - -[tool.ruff.lint] -extend-select = ["ALL"] -ignore = [ - "ANN101", - "ANN401", - "C90", - "COM812", - "D", # TODO - "ERA", - "FBT", - "INP001", - "ISC001", - "PTH", - "PL", - "S", - "SIM117", # TODO: remove wen dropping Python 3.8 support - "SLF", - "T201", - "UP006", # TODO: remove wen dropping Python 3.8 support -] -fixable = ["ALL"] +target-version = "py39" [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 + "FIX002", # Line contains 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 + "N811", # importing constant (MSS) as non-constant (mss) +] +"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: check for execution of untrusted input + "S607", # `subprocess` call without explicit paths + "SLF001", # private member accessed +] + +[tool.ruff.per-file-target-version] +"src/xcbproto/*" = "py312" diff --git a/src/mss/__init__.py b/src/mss/__init__.py index cb490e2f..ead3c23b 100644 --- a/src/mss/__init__.py +++ b/src/mss/__init__.py @@ -1,18 +1,19 @@ +# This module is maintained by Mickaël Schoentgen . +# +# You can always get the latest version of this module at: +# https://github.com/BoboTiG/python-mss +# If that URL should fail, try contacting the author. """An ultra fast cross-platform multiple screenshots module in pure python using ctypes. - -This module is maintained by Mickaël Schoentgen . - -You can always get the latest version of this module at: - https://github.com/BoboTiG/python-mss -If that URL should fail, try contacting the author. """ + +from mss.base import MSS, ScreenShot from mss.exception import ScreenShotError from mss.factory import mss -__version__ = "9.0.2" +__version__ = "10.2.0.dev0" __author__ = "Mickaël Schoentgen" -__date__ = "2013-2024" +__date__ = "2013-2026" __copyright__ = f""" Copyright (c) {__date__}, {__author__} @@ -23,4 +24,4 @@ in supporting documentation or portions thereof, including modifications, that you make. """ -__all__ = ("ScreenShotError", "mss") +__all__ = ("MSS", "ScreenShot", "ScreenShotError", "mss") diff --git a/src/mss/__main__.py b/src/mss/__main__.py index 9b74506f..4641b2ae 100644 --- a/src/mss/__main__.py +++ b/src/mss/__main__.py @@ -1,19 +1,39 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ + import os.path +import platform import sys -from argparse import ArgumentParser +from argparse import ArgumentError, ArgumentParser -from mss import __version__ +from mss import MSS, __version__ from mss.exception import ScreenShotError -from mss.factory import mss from mss.tools import to_png -def main(*args: str) -> int: +def _backend_cli_choices() -> list[str]: + os_name = platform.system().lower() + if os_name == "darwin": + from mss import darwin # noqa: PLC0415 + + return darwin.BACKENDS + if os_name == "linux": + from mss import linux # noqa: PLC0415 + + return linux.BACKENDS + if os_name == "windows": + from mss import windows # noqa: PLC0415 + + return windows.BACKENDS + return ["default"] + + +def main(*args: str) -> int: # noqa: PLR0912 """Main logic.""" - cli_args = ArgumentParser(prog="mss") + backend_choices = _backend_cli_choices() + + cli_args = ArgumentParser(prog="mss", exit_on_error=False) cli_args.add_argument( "-c", "--coordinates", @@ -29,9 +49,9 @@ def main(*args: str) -> int: choices=list(range(10)), help="the PNG compression level", ) - cli_args.add_argument("-m", "--monitor", default=0, type=int, help="the monitor to screen shot") + cli_args.add_argument("-m", "--monitor", default=0, type=int, help="the monitor to screenshot") cli_args.add_argument("-o", "--output", default="monitor-{mon}.png", help="the output file name") - cli_args.add_argument("--with-cursor", default=False, action="store_true", help="include the cursor") + cli_args.add_argument("--with-cursor", default=None, action="store_true", help="include the cursor") cli_args.add_argument( "-q", "--quiet", @@ -39,10 +59,20 @@ def main(*args: str) -> int: action="store_true", help="do not print created files", ) + cli_args.add_argument( + "-b", "--backend", default="default", choices=backend_choices, help="platform-specific backend to use" + ) cli_args.add_argument("-v", "--version", action="version", version=__version__) - options = cli_args.parse_args(args or None) - kwargs = {"mon": options.monitor, "output": options.output} + 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. + 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(",") @@ -50,25 +80,34 @@ def main(*args: str) -> int: print("Coordinates syntax: top, left, width, height") return 2 - kwargs["mon"] = { + grab_kwargs["mon"] = { "top": int(top), "left": int(left), "width": int(width), "height": int(height), } if options.output == "monitor-{mon}.png": - kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png" + grab_kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png" + + 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 try: - with mss(with_cursor=options.with_cursor) as sct: + with MSS(**mss_kwargs) as sct: if options.coordinates: - output = kwargs["output"].format(**kwargs["mon"]) - sct_img = sct.grab(kwargs["mon"]) + 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(**kwargs): + for file_name in sct.save(**grab_kwargs): if not options.quiet: print(os.path.realpath(file_name)) return 0 @@ -78,5 +117,9 @@ def main(*args: str) -> int: raise -if __name__ == "__main__": # pragma: nocover - sys.exit(main()) +if __name__ == "__main__": + try: + sys.exit(main()) + except ScreenShotError as exc: + print("[ERROR]", exc) + sys.exit(1) diff --git a/src/mss/base.py b/src/mss/base.py index 4495bf14..ef2c3e24 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -1,12 +1,14 @@ -"""This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss. -""" +# This is part of the MSS Python's module. +# Source: https://github.com/BoboTiG/python-mss. + from __future__ import annotations -from abc import ABCMeta, abstractmethod +import platform +import warnings +from abc import ABC, abstractmethod from datetime import datetime from threading import Lock -from typing import TYPE_CHECKING, Any, List, Tuple +from typing import TYPE_CHECKING, Any from mss.exception import ScreenShotError from mss.screenshot import ScreenShot @@ -15,7 +17,16 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator - from mss.models import Monitor, Monitors + from mss.models import Monitor, Monitors, Size + + # Prior to 3.11, Python didn't have the Self type. typing_extensions does, but we don't want to depend on it. + try: + from typing import Self + except ImportError: + try: + from typing_extensions import Self + except ImportError: + Self = Any # type: ignore[assignment] try: from datetime import UTC @@ -25,29 +36,226 @@ UTC = timezone.utc +#: Global lock protecting access to platform screenshot calls. +#: +#: .. versionadded:: 6.0.0 +#: +#: .. deprecated:: 10.2.0 +#: The global lock is no longer used, and will be removed in a future release. +#: MSS objects now have their own locks, which are not publicly-accessible. lock = Lock() +OPAQUE = 255 + + +# A sentinel value to indicate that a parameter was not passed, as opposed to being passed with a value of None. This +# is used in the MSS constructor to distinguish between the user not passing a parameter, and the user explicitly +# passing None (which is the default for some parameters). This allows us to preserve the existing behavior of ignoring +# certain parameters on certain platforms, while still allowing users to explicitly set those parameters on platforms +# where they are supported. +class _PlatformSpecific: + def __init__(self, sphinx_repr: Any) -> None: + self.sphinx_repr = str(sphinx_repr) + + def __repr__(self) -> str: + # This is used to get Sphinx to show a useful default when it shows the default in the summary, rather than + # an opaque object. + return self.sphinx_repr + + +__all__ = () + + +class MSSImplementation(ABC): + """Base class for internal platform/backend implementations. + + Only one of these methods will be called at a time; the containing + MSS object will hold a lock during these calls. + """ + + __slots__ = ("performance_status", "with_cursor") + + with_cursor: bool + + def __init__(self, /, *, with_cursor: bool = False) -> None: + # We put with_cursor on the MSSImplementation because the Xlib backend will turn it off if the library isn't + # installed. (It's not a separate library under XCB.) So, we need to let the backend mutate it. Note that + # the other platforms don't support with_cursor, and don't pass it to us. + # + # TODO(jholveck): #493 We should remove this expectation in 11.0. It seems unlikely to be practically useful, + # Xlib is legacy, and just complicates things. + self.with_cursor = with_cursor + + # Any notes the backend needs to give the user for debugging purposes, like why it had to fall back to a + # slower implementation. + self.performance_status: list[str] = [] + + @abstractmethod + def cursor(self) -> ScreenShot | None: + """Retrieve all cursor data. Pixels have to be RGB.""" + + @abstractmethod + def grab(self, monitor: Monitor, /) -> bytearray | tuple[bytearray, Size]: + """Retrieve all pixels from a monitor. Pixels have to be RGB. + + If the monitor size is not in pixel units, include a Size in + pixels (see issue #23). + """ + + @abstractmethod + def monitors(self) -> Monitors: + """Return positions of monitors.""" + + def close(self) -> None: # noqa: B027 - intentionally empty + """Clean up. + + This will be called at most once. + + It's not necessary for subclasses to implement this if they + have nothing to clean up. + """ + + @staticmethod + def _cfactory( + attr: Any, + func: str, + argtypes: list[Any], + restype: Any, + /, + errcheck: Callable | None = None, + ) -> None: + """Factory to create a ctypes function and automatically manage errors.""" + meth = getattr(attr, func) + meth.argtypes = argtypes + meth.restype = restype + if errcheck: + meth.errcheck = errcheck + + +def _choose_impl(**kwargs: Any) -> MSSImplementation: + """Return the backend implementation for the current platform. + + Detects the platform we are running on and instantiates the + appropriate internal implementation class. + + .. seealso:: + - :class:`mss.MSS` + - :class:`mss.darwin.MSS` + - :class:`mss.linux.MSS` + - :class:`mss.windows.MSS` + """ + os_ = platform.system().lower() + + if os_ == "darwin": + from mss.darwin import MSSImplDarwin # noqa: PLC0415 + + return MSSImplDarwin(**kwargs) + + if os_ == "linux": + from mss.linux import choose_impl as choose_impl_linux # noqa: PLC0415 + + # Linux has its own factory to choose the backend. + return choose_impl_linux(**kwargs) + + if os_ == "windows": + from mss.windows import MSSImplWindows # noqa: PLC0415 + + return MSSImplWindows(**kwargs) + + msg = f"System {os_!r} not (yet?) implemented." + raise ScreenShotError(msg) -class MSSBase(metaclass=ABCMeta): - """This class will be overloaded by a system specific one.""" - __slots__ = {"_monitors", "cls_image", "compression_level", "with_cursor"} +# Does this belong here? +class MSS: + """Multiple ScreenShots class + + :param backend: Backend selector, for platforms with multiple backends. + :param compression_level: PNG compression level. + :param with_cursor: Include the mouse cursor in screenshots + (GNU/Linux only) + :type display: bool, optional (default False) + :param display: X11 display name (GNU/Linux only). + :type display: bytes | str, optional (default :envvar:`$DISPLAY`) + :param max_displays: Maximum number of displays to enumerate (macOS only). + :type max_displays: int, optional (default 32) + + .. versionadded:: 8.0.0 + ``compression_level``, ``display``, ``max_displays``, and + ``with_cursor`` keyword arguments. + + .. versionadded:: 10.2.0 + ``backend`` keyword argument. + """ + + # We want to: + # * Let Sphinx, IDEs, and code-checkers know all the possible kwargs. + # * Know if a user explicitly passed an unsupported platform-dependent keyword, so we can warn. + # * Show a meaningful default in the Sphinx doc's summary string + # + # To accomplish this: + # * We list the possibilities explicitly in the __init__ kwargs. + # * We use a sentinel value, so we can tell whether or not the user actually gave us a value. + # * We represent the "default value" sentinel object with something different, so Sphinx formats it usefully. + _PD_WITH_CURSOR = _PlatformSpecific(False) # noqa: FBT003 + _PD_DISPLAY = _PlatformSpecific(None) + _PD_MAX_DISPLAYS = _PlatformSpecific(32) def __init__( self, /, *, + backend: str = "default", compression_level: int = 6, - display: bytes | str | None = None, # noqa:ARG002 Linux only - max_displays: int = 32, # noqa:ARG002 Mac only - with_cursor: bool = False, + with_cursor: bool | _PlatformSpecific = _PD_WITH_CURSOR, + display: bytes | str | None | _PlatformSpecific = _PD_DISPLAY, + max_displays: int | _PlatformSpecific = _PD_MAX_DISPLAYS, ) -> None: + impl_kwargs = {} + + system = platform.system() + for name, value, supported_platforms in [ + ("with_cursor", with_cursor, ["Linux"]), + ("display", display, ["Linux"]), + ("max_displays", max_displays, ["Darwin"]), + ]: + if isinstance(value, _PlatformSpecific): + continue + if system not in supported_platforms: + # TODO(jholveck): #493 Accept platform-specific kwargs on all platforms for migration ease. Foreign + # kwargs are silently stripped with a warning. + warnings.warn( + f"{name} is only available on {', '.join(supported_platforms)}. " + "This will be an error in the future.", + DeprecationWarning, + stacklevel=2, + ) + else: + impl_kwargs[name] = value + + self._impl: MSSImplementation = _choose_impl( + backend=backend, + **impl_kwargs, + ) + + # The cls_image is only used atomically, so does not require locking. self.cls_image: type[ScreenShot] = ScreenShot + # The compression level is only used atomically, so does not require locking. + #: PNG compression level used when saving the screenshot data into a file + #: (see :py:func:`zlib.compress()` for details). + #: + #: .. versionadded:: 3.2.0 self.compression_level = compression_level - self.with_cursor = with_cursor - self._monitors: Monitors = [] - def __enter__(self) -> MSSBase: # noqa:PYI034 + # The attributes below are protected by self._lock. The attributes above are user-visible, so we don't + # control when they're modified. Currently, we only make sure that they're safe to modify while locked, or + # document that the user shouldn't change them. We could also use properties protect them against changes, or + # change them under the lock. + self._lock = Lock() + self._monitors: Monitors | None = None + self._closed = False + + def __enter__(self) -> Self: """For the cool call `with MSS() as mss:`.""" return self @@ -55,33 +263,36 @@ def __exit__(self, *_: object) -> None: """For the cool call `with MSS() as mss:`.""" self.close() - @abstractmethod - def _cursor_impl(self) -> ScreenShot | None: - """Retrieve all cursor data. Pixels have to be RGB.""" + def close(self) -> None: + """Clean up. - @abstractmethod - def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: - """Retrieve all pixels from a monitor. Pixels have to be RGB. - That method has to be run using a threading lock. - """ + This releases resources that MSS may be using. Once the MSS + object is closed, it may not be used again. - @abstractmethod - def _monitors_impl(self) -> None: - """Get positions of monitors (has to be run using a threading lock). - It must populate self._monitors. - """ + It is safe to call this multiple times; multiple calls have no + effect. + + Rather than use :py:meth:`close` explicitly, we recommend you + use the MSS object as a context manager:: - def close(self) -> None: # noqa:B027 - """Clean-up.""" + with mss.MSS() as sct: + ... + """ + with self._lock: + if self._closed: + return + self._impl.close() + self._closed = True - def grab(self, monitor: Monitor | Tuple[int, int, int, int], /) -> ScreenShot: + def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: """Retrieve screen pixels for a given monitor. - Note: *monitor* can be a tuple like the one PIL.Image.grab() accepts. + Note: ``monitor`` can be a tuple like the one + :py:meth:`PIL.ImageGrab.grab` accepts: ``(left, top, right, bottom)`` :param monitor: The coordinates and size of the box to capture. See :meth:`monitors ` for object details. - :return :class:`ScreenShot `. + :returns: Screenshot of the requested region. """ # Convert PIL bbox style if isinstance(monitor, tuple): @@ -92,9 +303,19 @@ def grab(self, monitor: Monitor | Tuple[int, int, int, int], /) -> ScreenShot: "height": monitor[3] - monitor[1], } - with lock: - screenshot = self._grab_impl(monitor) - if self.with_cursor and (cursor := self._cursor_impl()): + if monitor["width"] <= 0 or monitor["height"] <= 0: + msg = f"Region has zero or negative size: {monitor!r}" + raise ScreenShotError(msg) + + with self._lock: + img_data_and_maybe_size = self._impl.grab(monitor) + if isinstance(img_data_and_maybe_size, tuple): + img_data, size = img_data_and_maybe_size + screenshot = self.cls_image(img_data, monitor, size=size) + else: + img_data = img_data_and_maybe_size + screenshot = self.cls_image(img_data, monitor) + if self._impl.with_cursor and (cursor := self._impl.cursor()): return self._merge(screenshot, cursor) return screenshot @@ -104,24 +325,54 @@ def monitors(self) -> Monitors: If the monitor has rotation, you have to deal with it inside this method. - This method has to fill self._monitors with all information + This method has to fill ``self._monitors`` with all information and use it as a cache: - self._monitors[0] is a dict of all monitors together - self._monitors[N] is a dict of the monitor N (with N > 0) + + - ``self._monitors[0]`` is a dict of all monitors together + - ``self._monitors[N]`` is a dict of the monitor N (with N > 0) Each monitor is a dict with: - { - 'left': the x-coordinate of the upper-left corner, - 'top': the y-coordinate of the upper-left corner, - 'width': the width, - 'height': the height - } + + - ``left``: the x-coordinate of the upper-left corner + - ``top``: the y-coordinate of the upper-left corner + - ``width``: the width + - ``height``: the height + - ``is_primary``: (optional) true if this is the primary monitor + - ``name``: (optional) human-readable device name + - ``unique_id``: (optional) platform-specific stable identifier for the monitor + - ``output``: (optional, Linux only) monitor output name, compatible with xrandr + """ + with self._lock: + if self._monitors is None: + self._monitors = self._impl.monitors() + assert self._monitors is not None # noqa: S101 + return self._monitors + + @property + def primary_monitor(self) -> Monitor: + """Get the primary monitor. + + Returns the monitor marked as primary. If no monitor is marked as primary + (or the platform doesn't support primary monitor detection), returns the + first monitor (at index 1). + + :raises ScreenShotError: If no monitors are available. + + .. versionadded:: 10.2.0 """ - if not self._monitors: - with lock: - self._monitors_impl() + monitors = self.monitors + if len(monitors) <= 1: # Only the "all monitors" entry or empty + msg = "No monitor found." + raise ScreenShotError(msg) - return self._monitors + return next( + ( + monitor + for monitor in monitors[1:] # Skip the "all monitors" entry at index 0 + if monitor.get("is_primary", False) + ), + monitors[1], # Fallback to the first monitor if no primary is found + ) def save( self, @@ -131,30 +382,15 @@ def save( output: str = "monitor-{mon}.png", callback: Callable[[str], None] | None = None, ) -> Iterator[str]: - """Grab a screen shot and save it to a file. - - :param int mon: The monitor to screen shot (default=0). - -1: grab one screen shot of all monitors - 0: grab one screen shot by monitor - N: grab the screen shot of the monitor N - - :param str output: The output filename. - - It can take several keywords to customize the filename: - - `{mon}`: the monitor number - - `{top}`: the screen shot y-coordinate of the upper-left corner - - `{left}`: the screen shot x-coordinate of the upper-left corner - - `{width}`: the screen shot's width - - `{height}`: the screen shot's height - - `{date}`: the current date using the default formatter - - As it is using the `format()` function, you can specify - formatting options like `{date:%Y-%m-%s}`. - - :param callable callback: Callback called before saving the - screen shot to a file. Take the `output` argument as parameter. - - :return generator: Created file(s). + """Grab a screenshot and save it to a file. + + :param int mon: The monitor to screenshot (default=0). ``-1`` grabs all + monitors, ``0`` grabs each monitor, and ``N`` grabs monitor ``N``. + :param str output: The output filename. Keywords: ``{mon}``, ``{top}``, + ``{left}``, ``{width}``, ``{height}``, ``{date}``. + :param callable callback: Called before saving the screenshot; receives + the ``output`` argument. + :return: Created file(s). """ monitors = self.monitors if not monitors: @@ -162,7 +398,7 @@ def save( raise ScreenShotError(msg) if mon == 0: - # One screen shot by monitor + # One screenshot by monitor for idx, monitor in enumerate(monitors[1:], 1): fname = output.format(mon=idx, date=datetime.now(UTC) if "{date" in output else None, **monitor) if callable(callback): @@ -171,8 +407,8 @@ def save( to_png(sct.rgb, sct.size, level=self.compression_level, output=fname) yield fname else: - # A screen shot of all monitors together or - # a screen shot of the monitor N. + # A screenshot of all monitors together or + # a screenshot of the monitor N. mon = 0 if mon == -1 else mon try: monitor = monitors[mon] @@ -188,16 +424,18 @@ def save( yield output def shot(self, /, **kwargs: Any) -> str: - """Helper to save the screen shot of the 1st monitor, by default. - You can pass the same arguments as for ``save``. + """Helper to save the screenshot of the 1st monitor, by default. + You can pass the same arguments as for :meth:`save`. """ kwargs["mon"] = kwargs.get("mon", 1) return next(self.save(**kwargs)) @staticmethod def _merge(screenshot: ScreenShot, cursor: ScreenShot, /) -> ScreenShot: - """Create composite image by blending screenshot and mouse cursor.""" + """Create composite image by blending screenshot and mouse cursor. + The cursor image should be in straight (not premultiplied) alpha. + """ (cx, cy), (cw, ch) = cursor.pos, cursor.size (x, y), (w, h) = screenshot.pos, screenshot.size @@ -231,7 +469,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 @@ -244,7 +482,7 @@ def _merge(screenshot: ScreenShot, cursor: ScreenShot, /) -> ScreenShot: def _cfactory( attr: Any, func: str, - argtypes: List[Any], + argtypes: list[Any], restype: Any, /, errcheck: Callable | None = None, @@ -255,3 +493,59 @@ def _cfactory( meth.restype = restype if errcheck: meth.errcheck = errcheck + + # Some backends may expose additional read-only attributes. Those + # are implemented here, as properties. By making them properties, + # instead of using __getattr__, they're also accessible to Sphinx + # and type checkers. + # + # Important: We need to be judicious in what we add here. We + # really don't want these to proliferate. Some, like + # max_displays, should probably be removed in 11.0. with_cursor + # should probably be moved to MSS instead of MSSImplementation (as + # noted there). + + @property + def performance_status(self) -> list[str]: + """Implementation-specific notes that might affect performance. + + For instance, on GNU/Linux, when using the default XShmGetImage + backend, this will include a note if the MIT-SHM extension is + not usable. + + This may not be ready until one screenshot has been taken. + + This is meant only for debugging purposes; the contents are + subject to change at any time. + + .. versionadded:: 10.2.0 + """ + return self._impl.performance_status + + @property + def max_displays(self) -> int: + """Maximum number of displays to handle. + + Availability: macOS + + .. versionadded:: 8.0.0 + """ + return self._impl.max_displays # type: ignore[attr-defined] + + @property + def with_cursor(self) -> bool: + """Include the mouse cursor in screenshots. + + In some circumstances, it may not be possible to include the + cursor. In that case, MSS will automatically change this to + False when the object is created. + + This cannot be changed after creating the object. + + .. versionadded:: 8.0.0 + """ + return self._impl.with_cursor + + +# TODO(jholveck): #493 Remove compatibility alias after 10.x transition period. +MSSBase = MSS diff --git a/src/mss/darwin.py b/src/mss/darwin.py index f247c516..62f2d0e6 100644 --- a/src/mss/darwin.py +++ b/src/mss/darwin.py @@ -1,23 +1,68 @@ -"""This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss. +"""macOS CoreGraphics backend for MSS. + +Uses the CoreGraphics APIs to capture windows and enumerates up to +``max_displays`` active displays. """ + from __future__ import annotations import ctypes import ctypes.util import sys -from ctypes import POINTER, Structure, c_double, c_float, c_int32, c_ubyte, c_uint32, c_uint64, c_void_p +import warnings +from ctypes import ( + POINTER, + Structure, + c_double, + c_float, + c_int32, + c_long, + c_size_t, + c_ubyte, + c_uint32, + c_void_p, +) from platform import mac_ver -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from mss.base import MSSBase +from mss.base import MSS as _MSS +from mss.base import MSSImplementation from mss.exception import ScreenShotError -from mss.screenshot import ScreenShot, Size +from mss.screenshot import Size if TYPE_CHECKING: - from mss.models import CFunctions, Monitor + from typing import Any + + from mss.models import CFunctions, Monitor, Monitors + +__all__ = ("IMAGE_OPTIONS", "MSS") + +BACKENDS = ["default"] + +MAC_VERSION_CATALINA = 10.16 + +kCGWindowImageBoundsIgnoreFraming = 1 << 0 # noqa: N816 +kCGWindowImageNominalResolution = 1 << 4 # noqa: N816 +kCGWindowImageShouldBeOpaque = 1 << 1 # noqa: N816 +#: For advanced users: as a note, you can set ``IMAGE_OPTIONS = 0`` to turn on scaling; see issue #257 for more +#: information. +IMAGE_OPTIONS: int = kCGWindowImageBoundsIgnoreFraming | kCGWindowImageShouldBeOpaque | kCGWindowImageNominalResolution -__all__ = ("MSS",) + +class MSS(_MSS): + """Deprecated macOS compatibility constructor. + + Use :class:`mss.MSS` instead. + """ + + def __init__(self, /, **kwargs: Any) -> None: + # TODO(jholveck): #493 Remove compatibility constructor after 10.x transition period. + warnings.warn( + "mss.darwin.MSS is deprecated and will be removed in 11.0; use mss.MSS instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) def cgfloat() -> type[c_double | c_float]: @@ -58,38 +103,50 @@ 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), - "CFDataGetBytePtr": ("core", [c_void_p], c_void_p), - "CFDataGetLength": ("core", [c_void_p], c_uint64), - "CFRelease": ("core", [c_void_p], c_void_p), - "CGDataProviderRelease": ("core", [c_void_p], c_void_p), + "CGDisplayRotation": ("core", [c_uint32], c_double), + "CFDataGetBytePtr": ("core", [c_void_p], POINTER(c_ubyte)), + "CFDataGetLength": ("core", [c_void_p], c_long), + "CFRelease": ("core", [c_void_p], None), "CGGetActiveDisplayList": ("core", [c_uint32, POINTER(c_uint32), POINTER(c_uint32)], c_int32), - "CGImageGetBitsPerPixel": ("core", [c_void_p], int), - "CGImageGetBytesPerRow": ("core", [c_void_p], int), + "CGImageGetBitsPerPixel": ("core", [c_void_p], c_size_t), + "CGImageGetBytesPerRow": ("core", [c_void_p], c_size_t), "CGImageGetDataProvider": ("core", [c_void_p], c_void_p), - "CGImageGetHeight": ("core", [c_void_p], int), - "CGImageGetWidth": ("core", [c_void_p], int), + "CGImageGetHeight": ("core", [c_void_p], c_size_t), + "CGImageGetWidth": ("core", [c_void_p], c_size_t), "CGRectStandardize": ("core", [CGRect], CGRect), "CGRectUnion": ("core", [CGRect, CGRect], CGRect), "CGWindowListCreateImage": ("core", [CGRect, c_uint32, c_uint32, c_uint32], c_void_p), } -class MSS(MSSBase): +class MSSImplDarwin(MSSImplementation): """Multiple ScreenShots implementation for macOS. It uses intensively the CoreGraphics library. + + :param max_displays: maximum number of displays to handle (default: 32). + :type max_displays: int + + .. seealso:: + + :py:class:`mss.MSS` + Lists other parameters. """ __slots__ = {"core", "max_displays"} - def __init__(self, /, **kwargs: Any) -> None: - """MacOS initialisations.""" - super().__init__(**kwargs) + def __init__(self, *, backend: str = "default", max_displays: int = 32) -> None: + super().__init__() + + if backend != "default": + msg = 'The only valid backend on this platform is "default".' + raise ScreenShotError(msg) - self.max_displays = kwargs.get("max_displays", 32) + # max_displays is only used by monitors(), while the lock is held. + #: Maximum number of displays to handle. + self.max_displays = max_displays self._init_library() self._set_cfunctions() @@ -97,7 +154,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 @@ -106,6 +163,7 @@ def _init_library(self) -> None: if not coregraphics: msg = "No CoreGraphics library found." raise ScreenShotError(msg) + # :meta:private: self.core = ctypes.cdll.LoadLibrary(coregraphics) def _set_cfunctions(self) -> None: @@ -115,16 +173,18 @@ def _set_cfunctions(self) -> None: for func, (attr, argtypes, restype) in CFUNCTIONS.items(): cfactory(attrs[attr], func, argtypes, restype) - def _monitors_impl(self) -> None: - """Get positions of monitors. It will populate self._monitors.""" + def monitors(self) -> Monitors: + """Get positions of monitors.""" int_ = int core = self.core + monitors: Monitors = [] + # All monitors # We need to update the value with every single monitor found # using CGRectUnion. Else we will end with infinite values. all_monitors = CGRect() - self._monitors.append({}) + monitors.append({}) # Each monitor display_count = c_uint32(0) @@ -135,10 +195,14 @@ 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( + + monitors.append( { "left": int_(rect.origin.x), "top": int_(rect.origin.y), @@ -151,27 +215,28 @@ def _monitors_impl(self) -> None: all_monitors = core.CGRectUnion(all_monitors, rect) # Set the AiO monitor's values - self._monitors[0] = { + monitors[0] = { "left": int_(all_monitors.origin.x), "top": int_(all_monitors.origin.y), "width": int_(all_monitors.size.width), "height": int_(all_monitors.size.height), } - def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: - """Retrieve all pixels from a monitor. Pixels have to be RGB.""" + return monitors + def grab(self, monitor: Monitor, /) -> tuple[bytearray, Size]: + """Retrieve all pixels from a monitor. Pixels have to be RGB.""" core = self.core rect = CGRect((monitor["left"], monitor["top"]), (monitor["width"], monitor["height"])) - image_ref = core.CGWindowListCreateImage(rect, 1, 0, 0) + image_ref = core.CGWindowListCreateImage(rect, 1, 0, IMAGE_OPTIONS) if not image_ref: msg = "CoreGraphics.CGWindowListCreateImage() failed." raise ScreenShotError(msg) width = core.CGImageGetWidth(image_ref) height = core.CGImageGetHeight(image_ref) - prov = copy_data = None + copy_data = None try: prov = core.CGImageGetDataProvider(image_ref) copy_data = core.CGDataProviderCopyData(prov) @@ -193,13 +258,12 @@ def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: cropped.extend(data[start:end]) data = cropped finally: - if prov: - core.CGDataProviderRelease(prov) if copy_data: core.CFRelease(copy_data) + core.CFRelease(image_ref) - return self.cls_image(data, monitor, size=Size(width, height)) + return data, Size(width, height) - def _cursor_impl(self) -> ScreenShot | None: + def cursor(self) -> None: """Retrieve all cursor data. Pixels have to be RGB.""" - return None + return diff --git a/src/mss/exception.py b/src/mss/exception.py index 42013672..5c8b08f3 100644 --- a/src/mss/exception.py +++ b/src/mss/exception.py @@ -1,14 +1,21 @@ -"""This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss. -""" +# This is part of the MSS Python's module. +# Source: https://github.com/BoboTiG/python-mss. + from __future__ import annotations -from typing import Any, Dict +from typing import Any class ScreenShotError(Exception): """Error handling class.""" - def __init__(self, message: str, /, *, details: Dict[str, Any] | None = None) -> None: + def __init__(self, message: str, /, *, details: dict[str, Any] | None = None) -> None: super().__init__(message) + #: On GNU/Linux, and if the error comes from the XServer, it contains XError details. + #: This is an empty dict by default. + #: + #: For XErrors, you can find information on + #: `Using the Default Error Handlers `_. + #: + #: .. versionadded:: 3.3.0 self.details = details or {} diff --git a/src/mss/factory.py b/src/mss/factory.py index fea7df31..91a46516 100644 --- a/src/mss/factory.py +++ b/src/mss/factory.py @@ -1,40 +1,22 @@ -"""This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss. -""" -import platform -from typing import Any +# This is part of the MSS Python's module. +# Source: https://github.com/BoboTiG/python-mss. -from mss.base import MSSBase -from mss.exception import ScreenShotError +import warnings +from typing import Any +from mss.base import MSS -def mss(**kwargs: Any) -> MSSBase: - """Factory returning a proper MSS class instance. - It detects the platform we are running on - and chooses the most adapted mss_class to take - screenshots. +def mss(**kwargs: Any) -> MSS: + """Create an :class:`mss.MSS` instance for the current platform. - It then proxies its arguments to the class for - instantiation. + .. deprecated:: 10.2.0 + Use :class:`mss.MSS` directly. """ - - os_ = platform.system().lower() - - if os_ == "darwin": - from mss import darwin - - return darwin.MSS(**kwargs) - - if os_ == "linux": - from mss import linux - - return linux.MSS(**kwargs) - - if os_ == "windows": - from mss import windows - - return windows.MSS(**kwargs) - - msg = f"System {os_!r} not (yet?) implemented." - raise ScreenShotError(msg) + # TODO(jholveck): #493 Remove compatibility deprecation path once 10.x transition period ends. + warnings.warn( + "mss.mss is deprecated and will be removed in a future release; use mss.MSS instead", + DeprecationWarning, + stacklevel=2, + ) + return MSS(**kwargs) diff --git a/src/mss/linux.py b/src/mss/linux.py deleted file mode 100644 index 7d0c8fca..00000000 --- a/src/mss/linux.py +++ /dev/null @@ -1,475 +0,0 @@ -"""This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss. -""" -from __future__ import annotations - -import os -from contextlib import suppress -from ctypes import ( - CFUNCTYPE, - POINTER, - Structure, - byref, - c_char_p, - c_int, - c_int32, - c_long, - c_short, - c_ubyte, - c_uint, - c_uint32, - c_ulong, - c_ushort, - c_void_p, - cast, - cdll, - create_string_buffer, -) -from ctypes.util import find_library -from threading import current_thread, local -from typing import TYPE_CHECKING, Any, Tuple - -from mss.base import MSSBase, lock -from mss.exception import ScreenShotError - -if TYPE_CHECKING: - from mss.models import CFunctions, Monitor - from mss.screenshot import ScreenShot - -__all__ = ("MSS",) - - -PLAINMASK = 0x00FFFFFF -ZPIXMAP = 2 - - -class Display(Structure): - """Structure that serves as the connection to the X server - and that contains all the information about that X server. - https://github.com/garrybodsworth/pyxlib-ctypes/blob/master/pyxlib/xlib.py#L831. - """ - - -class XErrorEvent(Structure): - """XErrorEvent to debug eventual errors. - https://tronche.com/gui/x/xlib/event-handling/protocol-errors/default-handlers.html. - """ - - _fields_ = ( - ("type", c_int), - ("display", POINTER(Display)), # Display the event was read from - ("serial", c_ulong), # serial number of failed request - ("error_code", c_ubyte), # error code of failed request - ("request_code", c_ubyte), # major op-code of failed request - ("minor_code", c_ubyte), # minor op-code of failed request - ("resourceid", c_void_p), # resource ID - ) - - -class XFixesCursorImage(Structure): - """Cursor structure. - /usr/include/X11/extensions/Xfixes.h - https://github.com/freedesktop/xorg-libXfixes/blob/libXfixes-6.0.0/include/X11/extensions/Xfixes.h#L96. - """ - - _fields_ = ( - ("x", c_short), - ("y", c_short), - ("width", c_ushort), - ("height", c_ushort), - ("xhot", c_ushort), - ("yhot", c_ushort), - ("cursor_serial", c_ulong), - ("pixels", POINTER(c_ulong)), - ("atom", c_ulong), - ("name", c_char_p), - ) - - -class XImage(Structure): - """Description of an image as it exists in the client's memory. - https://tronche.com/gui/x/xlib/graphics/images.html. - """ - - _fields_ = ( - ("width", c_int), # size of image - ("height", c_int), # size of image - ("xoffset", c_int), # number of pixels offset in X direction - ("format", c_int), # XYBitmap, XYPixmap, ZPixmap - ("data", c_void_p), # pointer to image data - ("byte_order", c_int), # data byte order, LSBFirst, MSBFirst - ("bitmap_unit", c_int), # quant. of scanline 8, 16, 32 - ("bitmap_bit_order", c_int), # LSBFirst, MSBFirst - ("bitmap_pad", c_int), # 8, 16, 32 either XY or ZPixmap - ("depth", c_int), # depth of image - ("bytes_per_line", c_int), # accelarator to next line - ("bits_per_pixel", c_int), # bits per pixel (ZPixmap) - ("red_mask", c_ulong), # bits in z arrangment - ("green_mask", c_ulong), # bits in z arrangment - ("blue_mask", c_ulong), # bits in z arrangment - ) - - -class XRRCrtcInfo(Structure): - """Structure that contains CRTC information. - https://gitlab.freedesktop.org/xorg/lib/libxrandr/-/blob/master/include/X11/extensions/Xrandr.h#L360. - """ - - _fields_ = ( - ("timestamp", c_ulong), - ("x", c_int), - ("y", c_int), - ("width", c_uint), - ("height", c_uint), - ("mode", c_long), - ("rotation", c_int), - ("noutput", c_int), - ("outputs", POINTER(c_long)), - ("rotations", c_ushort), - ("npossible", c_int), - ("possible", POINTER(c_long)), - ) - - -class XRRModeInfo(Structure): - """https://gitlab.freedesktop.org/xorg/lib/libxrandr/-/blob/master/include/X11/extensions/Xrandr.h#L248.""" - - -class XRRScreenResources(Structure): - """Structure that contains arrays of XIDs that point to the - available outputs and associated CRTCs. - https://gitlab.freedesktop.org/xorg/lib/libxrandr/-/blob/master/include/X11/extensions/Xrandr.h#L265. - """ - - _fields_ = ( - ("timestamp", c_ulong), - ("configTimestamp", c_ulong), - ("ncrtc", c_int), - ("crtcs", POINTER(c_long)), - ("noutput", c_int), - ("outputs", POINTER(c_long)), - ("nmode", c_int), - ("modes", POINTER(XRRModeInfo)), - ) - - -class XWindowAttributes(Structure): - """Attributes for the specified window.""" - - _fields_ = ( - ("x", c_int32), # location of window - ("y", c_int32), # location of window - ("width", c_int32), # width of window - ("height", c_int32), # height of window - ("border_width", c_int32), # border width of window - ("depth", c_int32), # depth of window - ("visual", c_ulong), # the associated visual structure - ("root", c_ulong), # root of screen containing window - ("class", c_int32), # InputOutput, InputOnly - ("bit_gravity", c_int32), # one of bit gravity values - ("win_gravity", c_int32), # one of the window gravity values - ("backing_store", c_int32), # NotUseful, WhenMapped, Always - ("backing_planes", c_ulong), # planes to be preserved if possible - ("backing_pixel", c_ulong), # value to be used when restoring planes - ("save_under", c_int32), # boolean, should bits under be saved? - ("colormap", c_ulong), # color map to be associated with window - ("mapinstalled", c_uint32), # boolean, is color map currently installed - ("map_state", c_uint32), # IsUnmapped, IsUnviewable, IsViewable - ("all_event_masks", c_ulong), # set of events all people have interest in - ("your_event_mask", c_ulong), # my event mask - ("do_not_propagate_mask", c_ulong), # set of events that should not propagate - ("override_redirect", c_int32), # boolean value for override-redirect - ("screen", c_ulong), # back pointer to correct screen - ) - - -_ERROR = {} -_X11 = find_library("X11") -_XFIXES = find_library("Xfixes") -_XRANDR = find_library("Xrandr") - - -@CFUNCTYPE(c_int, POINTER(Display), POINTER(XErrorEvent)) -def _error_handler(display: Display, event: XErrorEvent) -> int: - """Specifies the program's supplied error handler.""" - # Get the specific error message - xlib = cdll.LoadLibrary(_X11) # type: ignore[arg-type] - get_error = xlib.XGetErrorText - get_error.argtypes = [POINTER(Display), c_int, c_char_p, c_int] - get_error.restype = c_void_p - - evt = event.contents - error = create_string_buffer(1024) - get_error(display, evt.error_code, error, len(error)) - - _ERROR[current_thread()] = { - "error": error.value.decode("utf-8"), - "error_code": evt.error_code, - "minor_code": evt.minor_code, - "request_code": evt.request_code, - "serial": evt.serial, - "type": evt.type, - } - - return 0 - - -def _validate(retval: int, func: Any, args: Tuple[Any, Any], /) -> Tuple[Any, Any]: - """Validate the returned value of a C function call.""" - thread = current_thread() - if retval != 0 and thread not in _ERROR: - return args - - details = _ERROR.pop(thread, {}) - msg = f"{func.__name__}() failed" - raise ScreenShotError(msg, details=details) - - -# C functions that will be initialised later. -# See https://tronche.com/gui/x/xlib/function-index.html for details. -# -# Available attr: xfixes, xlib, xrandr. -# -# Note: keep it sorted by cfunction. -CFUNCTIONS: CFunctions = { - # cfunction: (attr, argtypes, restype) - "XCloseDisplay": ("xlib", [POINTER(Display)], c_void_p), - "XDefaultRootWindow": ("xlib", [POINTER(Display)], POINTER(XWindowAttributes)), - "XDestroyImage": ("xlib", [POINTER(XImage)], c_void_p), - "XFixesGetCursorImage": ("xfixes", [POINTER(Display)], POINTER(XFixesCursorImage)), - "XGetImage": ( - "xlib", - [POINTER(Display), POINTER(Display), c_int, c_int, c_uint, c_uint, c_ulong, c_int], - POINTER(XImage), - ), - "XGetWindowAttributes": ("xlib", [POINTER(Display), POINTER(XWindowAttributes), POINTER(XWindowAttributes)], c_int), - "XOpenDisplay": ("xlib", [c_char_p], POINTER(Display)), - "XQueryExtension": ("xlib", [POINTER(Display), c_char_p, POINTER(c_int), POINTER(c_int), POINTER(c_int)], c_uint), - "XRRFreeCrtcInfo": ("xrandr", [POINTER(XRRCrtcInfo)], c_void_p), - "XRRFreeScreenResources": ("xrandr", [POINTER(XRRScreenResources)], c_void_p), - "XRRGetCrtcInfo": ("xrandr", [POINTER(Display), POINTER(XRRScreenResources), c_long], POINTER(XRRCrtcInfo)), - "XRRGetScreenResources": ("xrandr", [POINTER(Display), POINTER(Display)], POINTER(XRRScreenResources)), - "XRRGetScreenResourcesCurrent": ("xrandr", [POINTER(Display), POINTER(Display)], POINTER(XRRScreenResources)), - "XSetErrorHandler": ("xlib", [c_void_p], c_void_p), -} - - -class MSS(MSSBase): - """Multiple ScreenShots implementation for GNU/Linux. - It uses intensively the Xlib and its Xrandr extension. - """ - - __slots__ = {"xfixes", "xlib", "xrandr", "_handles"} - - def __init__(self, /, **kwargs: Any) -> None: - """GNU/Linux initialisations.""" - super().__init__(**kwargs) - - # Available thread-specific variables - self._handles = local() - self._handles.display = None - self._handles.drawable = None - self._handles.original_error_handler = None - self._handles.root = None - - display = kwargs.get("display", b"") - if not display: - try: - display = os.environ["DISPLAY"].encode("utf-8") - except KeyError: - msg = "$DISPLAY not set." - raise ScreenShotError(msg) from None - - if not isinstance(display, bytes): - display = display.encode("utf-8") - - if b":" not in display: - msg = f"Bad display value: {display!r}." - raise ScreenShotError(msg) - - if not _X11: - msg = "No X11 library found." - raise ScreenShotError(msg) - self.xlib = cdll.LoadLibrary(_X11) - - if not _XRANDR: - msg = "No Xrandr extension found." - raise ScreenShotError(msg) - self.xrandr = cdll.LoadLibrary(_XRANDR) - - if self.with_cursor: - if _XFIXES: - self.xfixes = cdll.LoadLibrary(_XFIXES) - else: - self.with_cursor = False - - self._set_cfunctions() - - # Install the error handler to prevent interpreter crashes: any error will raise a ScreenShotError exception - self._handles.original_error_handler = self.xlib.XSetErrorHandler(_error_handler) - - self._handles.display = self.xlib.XOpenDisplay(display) - if not self._handles.display: - msg = f"Unable to open display: {display!r}." - raise ScreenShotError(msg) - - if not self._is_extension_enabled("RANDR"): - msg = "Xrandr not enabled." - raise ScreenShotError(msg) - - self._handles.root = self.xlib.XDefaultRootWindow(self._handles.display) - - # Fix for XRRGetScreenResources and XGetImage: - # expected LP_Display instance instead of LP_XWindowAttributes - self._handles.drawable = cast(self._handles.root, POINTER(Display)) - - def close(self) -> None: - # Remove our error handler - if self._handles.original_error_handler: - # It's required when exiting MSS to prevent letting `_error_handler()` as default handler. - # Doing so would crash when using Tk/Tkinter, see issue #220. - # Interesting technical stuff can be found here: - # https://core.tcl-lang.org/tk/file?name=generic/tkError.c&ci=a527ef995862cb50 - # https://github.com/tcltk/tk/blob/b9cdafd83fe77499ff47fa373ce037aff3ae286a/generic/tkError.c - self.xlib.XSetErrorHandler(self._handles.original_error_handler) - self._handles.original_error_handler = None - - # Clean-up - if self._handles.display: - self.xlib.XCloseDisplay(self._handles.display) - self._handles.display = None - self._handles.drawable = None - self._handles.root = None - - # Also empty the error dict - _ERROR.clear() - - def _is_extension_enabled(self, name: str, /) -> bool: - """Return True if the given *extension* is enabled on the server.""" - with lock: - major_opcode_return = c_int() - first_event_return = c_int() - first_error_return = c_int() - - try: - self.xlib.XQueryExtension( - self._handles.display, - name.encode("latin1"), - byref(major_opcode_return), - byref(first_event_return), - byref(first_error_return), - ) - except ScreenShotError: - return False - return True - - def _set_cfunctions(self) -> None: - """Set all ctypes functions and attach them to attributes.""" - cfactory = self._cfactory - attrs = { - "xfixes": getattr(self, "xfixes", None), - "xlib": self.xlib, - "xrandr": self.xrandr, - } - for func, (attr, argtypes, restype) in CFUNCTIONS.items(): - with suppress(AttributeError): - errcheck = None if func == "XSetErrorHandler" else _validate - cfactory(attrs[attr], func, argtypes, restype, errcheck=errcheck) - - def _monitors_impl(self) -> None: - """Get positions of monitors. It will populate self._monitors.""" - display = self._handles.display - int_ = int - xrandr = self.xrandr - - # All monitors - gwa = XWindowAttributes() - self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa)) - self._monitors.append( - {"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)}, - ) - - # Each monitor - # A simple benchmark calling 10 times those 2 functions: - # XRRGetScreenResources(): 0.1755971429956844 s - # XRRGetScreenResourcesCurrent(): 0.0039125580078689 s - # The second is faster by a factor of 44! So try to use it first. - try: - mon = xrandr.XRRGetScreenResourcesCurrent(display, self._handles.drawable).contents - except AttributeError: - mon = xrandr.XRRGetScreenResources(display, self._handles.drawable).contents - - crtcs = mon.crtcs - for idx in range(mon.ncrtc): - crtc = xrandr.XRRGetCrtcInfo(display, mon, crtcs[idx]).contents - if crtc.noutput == 0: - xrandr.XRRFreeCrtcInfo(crtc) - continue - - self._monitors.append( - { - "left": int_(crtc.x), - "top": int_(crtc.y), - "width": int_(crtc.width), - "height": int_(crtc.height), - }, - ) - xrandr.XRRFreeCrtcInfo(crtc) - xrandr.XRRFreeScreenResources(mon) - - def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: - """Retrieve all pixels from a monitor. Pixels have to be RGB.""" - ximage = self.xlib.XGetImage( - self._handles.display, - self._handles.drawable, - monitor["left"], - monitor["top"], - monitor["width"], - monitor["height"], - PLAINMASK, - ZPIXMAP, - ) - - try: - bits_per_pixel = ximage.contents.bits_per_pixel - if bits_per_pixel != 32: - msg = f"[XImage] bits per pixel value not (yet?) implemented: {bits_per_pixel}." - raise ScreenShotError(msg) - - raw_data = cast( - ximage.contents.data, - POINTER(c_ubyte * monitor["height"] * monitor["width"] * 4), - ) - data = bytearray(raw_data.contents) - finally: - # Free - self.xlib.XDestroyImage(ximage) - - return self.cls_image(data, monitor) - - def _cursor_impl(self) -> ScreenShot: - """Retrieve all cursor data. Pixels have to be RGB.""" - # Read data of cursor/mouse-pointer - ximage = self.xfixes.XFixesGetCursorImage(self._handles.display) - if not (ximage and ximage.contents): - msg = "Cannot read XFixesGetCursorImage()" - raise ScreenShotError(msg) - - cursor_img: XFixesCursorImage = ximage.contents - region = { - "left": cursor_img.x - cursor_img.xhot, - "top": cursor_img.y - cursor_img.yhot, - "width": cursor_img.width, - "height": cursor_img.height, - } - - raw_data = cast(cursor_img.pixels, POINTER(c_ulong * region["height"] * region["width"])) - raw = bytearray(raw_data.contents) - - data = bytearray(region["height"] * region["width"] * 4) - data[3::4] = raw[3::8] - data[2::4] = raw[2::8] - data[1::4] = raw[1::8] - data[::4] = raw[::8] - - return self.cls_image(data, region) diff --git a/src/mss/linux/__init__.py b/src/mss/linux/__init__.py new file mode 100644 index 00000000..6531433e --- /dev/null +++ b/src/mss/linux/__init__.py @@ -0,0 +1,94 @@ +"""GNU/Linux backend dispatcher for X11 screenshot implementations.""" + +import warnings +from typing import Any + +from mss.base import MSS as _MSS +from mss.base import MSSImplementation +from mss.exception import ScreenShotError + +# TODO(jholveck): #493 Remove these legacy symbol re-exports after 10.x transition period. +from mss.linux.xlib import ( # noqa: F401 + CFUNCTIONS, + PLAINMASK, + ZPIXMAP, + Display, + XErrorEvent, + XFixesCursorImage, + XImage, + XRRCrtcInfo, + XRRModeInfo, + XRRScreenResources, + XWindowAttributes, +) + +__all__ = ["MSS"] + +BACKENDS = ["default", "xlib", "xgetimage", "xshmgetimage"] + + +class MSS(_MSS): + """Deprecated GNU/Linux compatibility constructor. + + Use :class:`mss.MSS` instead. + """ + + def __init__(self, /, **kwargs: Any) -> None: + # TODO(jholveck): #493 Remove compatibility constructor after 10.x transition period. + warnings.warn( + "mss.linux.MSS is deprecated and will be removed in 11.0; use mss.MSS instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) + + +def choose_impl(backend: str = "default", **kwargs: Any) -> MSSImplementation: + """Return a backend-specific MSS implementation for GNU/Linux. + + Selects and instantiates the appropriate X11 backend based on the + ``backend`` parameter. + + :param backend: Backend selector. Valid values: + + - ``"default"`` or ``"xshmgetimage"`` (default): XCB-based backend + using XShmGetImage with automatic fallback to XGetImage when MIT-SHM + is unavailable. + - ``"xgetimage"``: XCB-based backend using XGetImage. + - ``"xlib"``: Legacy Xlib-based backend retained for environments + without working XCB libraries. + + .. versionadded:: 10.2.0 Prior to this version, the + legacy Xlib implementation was the only available + backend. + + :param display: Optional keyword argument. Specifies an X11 display + string to connect to. The default is taken from the environment + variable :envvar:`DISPLAY`. + :type display: str | bytes | None + :param kwargs: Additional keyword arguments passed to the backend class. + :returns: An MSS backend implementation. + + .. versionadded:: 10.2.0 Prior to this version, this didn't exist: + GNU/Linux had a single implementation selected through + :class:`mss.linux.MSS`. + """ + backend = backend.lower() + if backend == "xlib": + from mss.linux.xlib import MSSImplXlib # noqa: PLC0415 + + return MSSImplXlib(**kwargs) + if backend == "xgetimage": + from mss.linux.xgetimage import MSSImplXGetImage # noqa: PLC0415 + + # Note that the xshmgetimage backend will automatically fall back to XGetImage calls if XShmGetImage isn't + # available. The only reason to use the xgetimage backend is if the user already knows that XShmGetImage + # isn't going to be supported. + return MSSImplXGetImage(**kwargs) + if backend in {"default", "xshmgetimage"}: + from mss.linux.xshmgetimage import MSSImplXShmGetImage # noqa: PLC0415 + + return MSSImplXShmGetImage(**kwargs) + assert backend not in BACKENDS # noqa: S101 + msg = f"Backend {backend!r} not (yet?) implemented." + raise ScreenShotError(msg) diff --git a/src/mss/linux/base.py b/src/mss/linux/base.py new file mode 100644 index 00000000..df702aed --- /dev/null +++ b/src/mss/linux/base.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode + +from mss.base import MSSImplementation +from mss.exception import ScreenShotError +from mss.linux import xcb +from mss.linux.xcb import LIB +from mss.screenshot import ScreenShot +from mss.tools import parse_edid + +if TYPE_CHECKING: + from ctypes import Array + + from mss.models import Monitor, Monitors + +__all__ = () + +SUPPORTED_DEPTHS = {24, 32} +SUPPORTED_BITS_PER_PIXEL = 32 +SUPPORTED_RED_MASK = 0xFF0000 +SUPPORTED_GREEN_MASK = 0x00FF00 +SUPPORTED_BLUE_MASK = 0x0000FF +ALL_PLANES = 0xFFFFFFFF # XCB doesn't define AllPlanes + + +class MSSImplXCBBase(MSSImplementation): + """Base class for XCB-based screenshot implementations. + + Provides common XCB initialization and monitor detection logic that can be + shared across different XCB screenshot methods (``XGetImage``, + ``XShmGetImage``, ``XComposite``, etc.). + + :param display: Optional keyword argument. + Specifies an X11 display string to connect to. The default is + taken from the environment variable :envvar:`DISPLAY`. + :type display: str | bytes | None + + .. seealso:: + :py:class:`mss.MSS` + Lists other parameters. + """ + + def __init__(self, *, display: str | bytes | None = None, with_cursor: bool = False) -> None: # noqa: PLR0912 + super().__init__(with_cursor=with_cursor) + + if not display: + display = None + elif isinstance(display, str): + display = display.encode("utf-8") + + self.conn: xcb.Connection | None + self.conn, pref_screen_num = xcb.connect(display) + + # Get the connection setup information that was included when we connected. + xcb_setup = xcb.get_setup(self.conn) + screens = xcb.setup_roots(xcb_setup) + # pref_screen_num is the screen object corresponding to the screen number, e.g., 1 if DISPLAY=":0.1". It's + # almost always the only screen (screen 0); nobody uses separate screens (in the X sense) anymore. + self.pref_screen = screens[pref_screen_num] + self.root = self.drawable = self.pref_screen.root + + # We don't probe the XFixes presence or version until we need it. + self._xfixes_ready: bool | None = None + + # Probe the visuals (and related information), and make sure that our drawable is in an acceptable format. + # These iterations and tests don't involve any traffic with the server; it's all stuff that was included in + # the connection setup. Effectively all modern setups will be acceptable, but we verify to be sure. + + # Currently, we assume that the drawable we're capturing is the root; when we add single-window capture, + # we'll have to ask the server for its depth and visual. + assert self.root == self.drawable # noqa: S101 + self.drawable_depth = self.pref_screen.root_depth + self.drawable_visual_id = self.pref_screen.root_visual + # Server image byte order + if xcb_setup.image_byte_order != xcb.ImageOrder.LSBFirst: + msg = "Only X11 servers using LSB-First images are supported." + raise ScreenShotError(msg) + # Depth + if self.drawable_depth not in SUPPORTED_DEPTHS: + msg = f"Only screens of color depth 24 or 32 are supported, not {self.drawable_depth}" + raise ScreenShotError(msg) + # Format (i.e., bpp, padding) + for format_ in xcb.setup_pixmap_formats(xcb_setup): + if format_.depth == self.drawable_depth: + break + else: + msg = f"Internal error: drawable's depth {self.drawable_depth} not found in screen's supported formats" + raise ScreenShotError(msg) + drawable_format = format_ + if drawable_format.bits_per_pixel != SUPPORTED_BITS_PER_PIXEL: + msg = ( + f"Only screens at 32 bpp (regardless of color depth) are supported; " + f"got {drawable_format.bits_per_pixel} bpp" + ) + raise ScreenShotError(msg) + if drawable_format.scanline_pad != SUPPORTED_BITS_PER_PIXEL: + # To clarify the padding: the scanline_pad is the multiple that the scanline gets padded to. If there + # is no padding, then it will be the same as one pixel's size. + msg = "Screens with scanline padding are not supported" + raise ScreenShotError(msg) + # Visual, the interpretation of pixels (like indexed, grayscale, etc). (Visuals are arranged by depth, so + # we iterate over the depths first.) + for xcb_depth in xcb.screen_allowed_depths(self.pref_screen): + if xcb_depth.depth == self.drawable_depth: + break + else: + msg = "Internal error: drawable's depth not found in screen's supported depths" + raise ScreenShotError(msg) + for visual_info in xcb.depth_visuals(xcb_depth): + if visual_info.visual_id == self.drawable_visual_id: + break + else: + msg = "Internal error: drawable's visual not found in screen's supported visuals" + raise ScreenShotError(msg) + if visual_info.class_ not in {xcb.VisualClass.TrueColor, xcb.VisualClass.DirectColor}: + msg = "Only TrueColor and DirectColor visuals are supported" + raise ScreenShotError(msg) + if ( + visual_info.red_mask != SUPPORTED_RED_MASK + or visual_info.green_mask != SUPPORTED_GREEN_MASK + or visual_info.blue_mask != SUPPORTED_BLUE_MASK + ): + # There are two ways to phrase this layout: BGRx accounts for the byte order, while xRGB implies the + # native word order. Since we return the data as a byte array, we use the former. By the time we get + # to this point, we've already checked the endianness and depth, so this is pretty much never going to + # happen anyway. + msg = "Only visuals with BGRx ordering are supported" + raise ScreenShotError(msg) + + def close(self) -> None: + """Close the XCB connection.""" + if self.conn is not None: + xcb.disconnect(self.conn) + self.conn = None + + def monitors(self) -> Monitors: + """Populate monitor geometry information. + + Detects and returns monitor rectangles. The first entry + represents the entire X11 root screen; subsequent entries, + when available, represent individual monitors reported by + XRandR. + """ + if self.conn is None: + msg = "Cannot identify monitors while the connection is closed" + raise ScreenShotError(msg) + + monitors = [] + + monitors.append(self._root_monitor()) + + randr_version = self._randr_get_version() + if randr_version is None or randr_version < (1, 2): + return monitors + + # XRandR terminology (very abridged, but enough for this code): + # - X screen / framebuffer: the overall drawable area for this root. + # - CRTC: a display controller that scans out a rectangular region of the X screen. A CRTC with zero + # outputs is inactive. A CRTC may drive multiple outputs in clone/mirroring mode. + # - Output: a physical connector (e.g. "HDMI-1", "DP-1"). The RandR "connection" state (connected vs + # disconnected) is separate from whether the output is currently driven by a CRTC. + # - Monitor (RandR 1.5+): a logical rectangle presented to clients. Monitors may be client-defined (useful + # for tiled displays) and are the closest match to what MSS wants. + # + # This implementation prefers RandR 1.5+ Monitors when available; otherwise it falls back to enumerating + # active CRTCs. + + primary_output = self._randr_get_primary_output(randr_version) + edid_atom = self._randr_get_edid_atom() + + if randr_version >= (1, 5): + monitors += self._monitors_from_randr_monitors(primary_output, edid_atom) + else: + monitors += self._monitors_from_randr_crtcs(randr_version, primary_output, edid_atom) + + return monitors + + def _root_monitor(self) -> Monitor: + if self.conn is None: + msg = "Cannot identify monitors while the connection is closed" + raise ScreenShotError(msg) + + root_geom = xcb.get_geometry(self.conn, self.root) + return { + "left": root_geom.x, + "top": root_geom.y, + "width": root_geom.width, + "height": root_geom.height, + } + + def _randr_get_version(self) -> tuple[int, int] | None: + if self.conn is None: + msg = "Cannot identify monitors while the connection is closed" + raise ScreenShotError(msg) + + randr_ext_data = xcb.get_extension_data(self.conn, LIB.randr_id) + if not randr_ext_data.present: + return None + + randr_version_data = xcb.randr_query_version(self.conn, xcb.RANDR_MAJOR_VERSION, xcb.RANDR_MINOR_VERSION) + return (randr_version_data.major_version, randr_version_data.minor_version) + + def _randr_get_primary_output(self, randr_version: tuple[int, int], /) -> xcb.RandrOutput | None: + if self.conn is None: + msg = "Cannot identify monitors while the connection is closed" + raise ScreenShotError(msg) + + if randr_version >= (1, 3): + primary_output_data = xcb.randr_get_output_primary(self.conn, self.drawable) + return primary_output_data.output + # Python None means that there was no way to identify a primary output. This is distinct from XCB_NONE (that + # is, xcb.RandROutput(0)), which means that there is not a primary monitor. + return None + + def _randr_get_edid_atom(self) -> xcb.Atom | None: + if self.conn is None: + msg = "Cannot identify monitors while the connection is closed" + raise ScreenShotError(msg) + + edid_atom = xcb.intern_atom(self.conn, "EDID", only_if_exists=True) + if edid_atom is not None: + return edid_atom + + # Formerly, "EDID" was known as "EdidData". I don't know when it changed. + return xcb.intern_atom(self.conn, "EdidData", only_if_exists=True) + + def _randr_output_ids( + self, + output: xcb.RandrOutput, + timestamp: xcb.Timestamp, + edid_atom: xcb.Atom | None, + /, + ) -> dict[str, Any]: + if self.conn is None: + msg = "Cannot identify monitors while the connection is closed" + raise ScreenShotError(msg) + + output_info = xcb.randr_get_output_info(self.conn, output, timestamp) + if output_info.status != 0: + msg = "Display configuration changed while detecting monitors." + raise ScreenShotError(msg) + + rv: dict[str, Any] = {} + + output_name_arr = xcb.randr_get_output_info_name(output_info) + rv["output"] = bytes(output_name_arr).decode("utf_8", errors="replace") + + if edid_atom is not None: + edid_prop = xcb.randr_get_output_property( + self.conn, # connection + output, # output + edid_atom, # property + xcb.XCB_NONE, # property type: Any + 0, # long-offset: 0 + 1024, # long-length: in 4-byte units; 4k is plenty for an EDID + 0, # delete: false + 0, # pending: false + ) + if edid_prop.type_.value != 0: + edid_block = bytes(xcb.randr_get_output_property_data(edid_prop)) + edid_data = parse_edid(edid_block) + if (display_name := edid_data.get("display_name")) is not None: + rv["name"] = display_name + + edid_params: dict[str, str] = {} + if (id_legacy := edid_data.get("id_legacy")) is not None: + edid_params["model"] = id_legacy + if (serial_number := edid_data.get("serial_number")) is not None: + edid_params["serial"] = str(serial_number) + if (manufacture_year := edid_data.get("manufacture_year")) is not None: + if (manufacture_week := edid_data.get("manufacture_week")) is not None: + edid_params["mfr_date"] = f"{manufacture_year:04d}W{manufacture_week:02d}" + else: + edid_params["mfr_date"] = f"{manufacture_year:04d}" + if (model_year := edid_data.get("model_year")) is not None: + edid_params["model_year"] = f"{model_year:04d}" + if edid_params: + rv["unique_id"] = urlencode(edid_params) + + return rv + + @staticmethod + def _choose_randr_output( + outputs: Array[xcb.RandrOutput], primary_output: xcb.RandrOutput | None, / + ) -> xcb.RandrOutput: + if len(outputs) == 0: + msg = "No RandR outputs available" + raise ScreenShotError(msg) + if primary_output is None: + # We don't want to use the `in` check if this could be None, according to MyPy. + return outputs[0] + return primary_output if primary_output in outputs else outputs[0] + + def _monitors_from_randr_monitors( + self, primary_output: xcb.RandrOutput | None, edid_atom: xcb.Atom | None, / + ) -> Monitors: + if self.conn is None: + msg = "Cannot identify monitors while the connection is closed" + raise ScreenShotError(msg) + + monitors = [] + + monitors_reply = xcb.randr_get_monitors(self.conn, self.drawable, 1) + timestamp = monitors_reply.timestamp + for randr_monitor in xcb.randr_get_monitors_monitors(monitors_reply): + monitor = { + "left": randr_monitor.x, + "top": randr_monitor.y, + "width": randr_monitor.width, + "height": randr_monitor.height, + # Under XRandR, it's legal for no monitor to be primary. In this case, case MSSBase.primary_monitor + # will return the first monitor. That said, we note in the dict that we explicitly are told by XRandR + # that all of the monitors are not primary. (This is distinct from the XRandR 1.2 path, which doesn't + # have any information about primary monitors.) + "is_primary": bool(randr_monitor.primary), + } + + if randr_monitor.nOutput > 0: + outputs = xcb.randr_monitor_info_outputs(randr_monitor) + chosen_output = self._choose_randr_output(outputs, primary_output) + monitor |= self._randr_output_ids(chosen_output, timestamp, edid_atom) + + monitors.append(monitor) + + return monitors + + def _monitors_from_randr_crtcs( + self, + randr_version: tuple[int, int], + primary_output: xcb.RandrOutput | None, + edid_atom: xcb.Atom | None, + /, + ) -> Monitors: + if self.conn is None: + msg = "Cannot identify monitors while the connection is closed" + raise ScreenShotError(msg) + + monitors = [] + + screen_resources: xcb.RandrGetScreenResourcesReply | xcb.RandrGetScreenResourcesCurrentReply + if hasattr(LIB.randr, "xcb_randr_get_screen_resources_current") and randr_version >= (1, 3): + screen_resources = xcb.randr_get_screen_resources_current(self.conn, self.drawable) + crtcs = xcb.randr_get_screen_resources_current_crtcs(screen_resources) + else: + screen_resources = xcb.randr_get_screen_resources(self.conn, self.drawable) + crtcs = xcb.randr_get_screen_resources_crtcs(screen_resources) + timestamp = screen_resources.config_timestamp + + for crtc in crtcs: + crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, timestamp) + if crtc_info.num_outputs == 0: + continue + monitor = { + "left": crtc_info.x, + "top": crtc_info.y, + "width": crtc_info.width, + "height": crtc_info.height, + } + + outputs = xcb.randr_get_crtc_info_outputs(crtc_info) + chosen_output = self._choose_randr_output(outputs, primary_output) + monitor |= self._randr_output_ids(chosen_output, timestamp, edid_atom) + # The concept of primary outputs was added in XRandR 1.3. We distinguish between "all the monitors are + # not primary" (RRGetOutputPrimary returned XCB_NONE, a valid case) and "we have no way to get + # information about the primary monitor": in the latter case, we don't populate "is_primary". + if primary_output is not None: + monitor["is_primary"] = chosen_output == primary_output + + monitors.append(monitor) + + return monitors + + def _cursor_check_xfixes(self) -> bool: + """Check XFixes availability and version. + + :returns: ``True`` if the server provides XFixes with a compatible + version, otherwise ``False``. + """ + if self.conn is None: + msg = "Cannot take screenshot while the connection is closed" + raise ScreenShotError(msg) + + xfixes_ext_data = xcb.get_extension_data(self.conn, LIB.xfixes_id) + if not xfixes_ext_data.present: + return False + + reply = xcb.xfixes_query_version(self.conn, xcb.XFIXES_MAJOR_VERSION, xcb.XFIXES_MINOR_VERSION) + # We can work with 2.0 and later, but not sure about the actual minimum version we can use. That's ok; + # everything these days is much more modern. + return (reply.major_version, reply.minor_version) >= (2, 0) + + def cursor(self) -> ScreenShot: + """Capture the current cursor image. + + Pixels are returned in BGRA ordering. + + :returns: A screenshot object containing the cursor image and region. + """ + + if self.conn is None: + msg = "Cannot take screenshot while the connection is closed" + raise ScreenShotError(msg) + + if self._xfixes_ready is None: + self._xfixes_ready = self._cursor_check_xfixes() + if not self._xfixes_ready: + msg = "Server does not have XFixes, or the version is too old." + raise ScreenShotError(msg) + + cursor_img = xcb.xfixes_get_cursor_image(self.conn) + region = { + "left": cursor_img.x - cursor_img.xhot, + "top": cursor_img.y - cursor_img.yhot, + "width": cursor_img.width, + "height": cursor_img.height, + } + + data_arr = xcb.xfixes_get_cursor_image_cursor_image(cursor_img) + data = bytearray(data_arr) + # We don't need to do the same array slice-and-dice work as the Xlib-based implementation: Xlib has an + # unfortunate historical accident that makes it have to return the cursor image in a different format. + + return ScreenShot(data, region) + + def _grab_xgetimage(self, monitor: Monitor, /) -> bytearray: + """Retrieve pixels from a monitor using ``GetImage``. + + Used by the XGetImage backend and by the XShmGetImage backend in + fallback mode. + + :param monitor: Monitor rectangle specifying ``left``, ``top``, + ``width``, and ``height`` to capture. + :returns: A screenshot object containing the captured region. + """ + + if self.conn is None: + msg = "Cannot take screenshot while the connection is closed" + raise ScreenShotError(msg) + + img_reply = xcb.get_image( + self.conn, + xcb.ImageFormat.ZPixmap, + self.drawable, + monitor["left"], + monitor["top"], + monitor["width"], + monitor["height"], + ALL_PLANES, + ) + + # Now, save the image. This is a reference into the img_reply structure. + img_data_arr = xcb.get_image_data(img_reply) + # Copy this into a new bytearray, so that it will persist after we clear the image structure. + img_data = bytearray(img_data_arr) + + if img_reply.depth != self.drawable_depth or img_reply.visual != self.drawable_visual_id: + # This should never happen; a window can't change its visual. + msg = ( + "Server returned an image with a depth or visual different than it initially reported: " + f"expected {self.drawable_depth},{hex(self.drawable_visual_id.value)}, " + f"got {img_reply.depth},{hex(img_reply.visual.value)}" + ) + raise ScreenShotError(msg) + + return img_data diff --git a/src/mss/linux/xcb.py b/src/mss/linux/xcb.py new file mode 100644 index 00000000..b2ae41d1 --- /dev/null +++ b/src/mss/linux/xcb.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import contextlib +from ctypes import _Pointer, addressof, c_int +from typing import Literal, overload + +from mss.linux import xcbgen + +# We import these just so they're re-exported to our users. +# ruff: noqa: F401 +from mss.linux.xcbgen import ( + RANDR_MAJOR_VERSION, + RANDR_MINOR_VERSION, + RENDER_MAJOR_VERSION, + RENDER_MINOR_VERSION, + SHM_MAJOR_VERSION, + SHM_MINOR_VERSION, + XFIXES_MAJOR_VERSION, + XFIXES_MINOR_VERSION, + Atom, + BackingStore, + Colormap, + Depth, + DepthIterator, + Drawable, + Format, + GetGeometryReply, + GetImageReply, + GetPropertyReply, + ImageFormat, + ImageOrder, + Keycode, + Pixmap, + RandrConnection, + RandrCrtc, + RandrGetCrtcInfoReply, + RandrGetMonitorsReply, + RandrGetOutputInfoReply, + RandrGetOutputPrimaryReply, + RandrGetOutputPropertyReply, + RandrGetScreenResourcesCurrentReply, + RandrGetScreenResourcesReply, + RandrMode, + RandrModeInfo, + RandrMonitorInfo, + RandrMonitorInfoIterator, + RandrOutput, + RandrQueryVersionReply, + RandrSetConfig, + RenderDirectformat, + RenderPictdepth, + RenderPictdepthIterator, + RenderPictformat, + RenderPictforminfo, + RenderPictscreen, + RenderPictscreenIterator, + RenderPictType, + RenderPictvisual, + RenderQueryPictFormatsReply, + RenderQueryVersionReply, + RenderSubPixel, + Screen, + ScreenIterator, + Setup, + SetupIterator, + ShmCreateSegmentReply, + ShmGetImageReply, + ShmQueryVersionReply, + ShmSeg, + Timestamp, + VisualClass, + Visualid, + Visualtype, + Window, + XfixesGetCursorImageReply, + XfixesQueryVersionReply, + depth_visuals, + get_geometry, + get_image, + get_image_data, + get_property, + get_property_value, + no_operation, + randr_get_crtc_info, + randr_get_crtc_info_outputs, + randr_get_crtc_info_possible, + randr_get_monitors, + randr_get_monitors_monitors, + randr_get_output_info, + randr_get_output_info_clones, + randr_get_output_info_crtcs, + randr_get_output_info_modes, + randr_get_output_info_name, + randr_get_output_primary, + randr_get_output_property, + randr_get_output_property_data, + randr_get_screen_resources, + randr_get_screen_resources_crtcs, + randr_get_screen_resources_current, + randr_get_screen_resources_current_crtcs, + randr_get_screen_resources_current_modes, + randr_get_screen_resources_current_names, + randr_get_screen_resources_current_outputs, + randr_get_screen_resources_modes, + randr_get_screen_resources_names, + randr_get_screen_resources_outputs, + randr_monitor_info_outputs, + randr_query_version, + render_pictdepth_visuals, + render_pictscreen_depths, + render_query_pict_formats, + render_query_pict_formats_formats, + render_query_pict_formats_screens, + render_query_pict_formats_subpixels, + render_query_version, + screen_allowed_depths, + setup_pixmap_formats, + setup_roots, + setup_vendor, + shm_attach_fd, + shm_create_segment, + shm_create_segment_reply_fds, + shm_detach, + shm_get_image, + shm_query_version, + xfixes_get_cursor_image, + xfixes_get_cursor_image_cursor_image, + xfixes_query_version, +) + +# These are also here to re-export. +from mss.linux.xcbhelpers import LIB, XID, Connection, InternAtomReply, QueryExtensionReply, XcbExtension, XError + +XCB_CONN_ERROR = 1 +XCB_CONN_CLOSED_EXT_NOTSUPPORTED = 2 +XCB_CONN_CLOSED_MEM_INSUFFICIENT = 3 +XCB_CONN_CLOSED_REQ_LEN_EXCEED = 4 +XCB_CONN_CLOSED_PARSE_ERR = 5 +XCB_CONN_CLOSED_INVALID_SCREEN = 6 +XCB_CONN_CLOSED_FDPASSING_FAILED = 7 + +# I don't know of error descriptions for the XCB connection errors being accessible through a library (a la strerror), +# and the ones in xcb.h's comments aren't too great, so I wrote these. +XCB_CONN_ERRMSG = { + XCB_CONN_ERROR: "connection lost or could not be established", + XCB_CONN_CLOSED_EXT_NOTSUPPORTED: "extension not supported", + XCB_CONN_CLOSED_MEM_INSUFFICIENT: "memory exhausted", + XCB_CONN_CLOSED_REQ_LEN_EXCEED: "request length longer than server accepts", + XCB_CONN_CLOSED_PARSE_ERR: "display is unset or invalid (check $DISPLAY)", + XCB_CONN_CLOSED_INVALID_SCREEN: "server does not have a screen matching the requested display", + XCB_CONN_CLOSED_FDPASSING_FAILED: "could not pass file descriptor", +} + + +#### High-level XCB function wrappers + +# XCB_NONE is the universal null resource or null atom parameter value for many core X requests +XCB_NONE = Atom(0) + +# Some atoms are defined by the spec, to avoid apps having to look them up. It's fine to look them up anyway. +_PREDEFINED_ATOMS = { + "PRIMARY": Atom(1), + "SECONDARY": Atom(2), + "ARC": Atom(3), + "ATOM": Atom(4), + "BITMAP": Atom(5), + "CARDINAL": Atom(6), + "COLORMAP": Atom(7), + "CURSOR": Atom(8), + "CUT_BUFFER0": Atom(9), + "CUT_BUFFER1": Atom(10), + "CUT_BUFFER2": Atom(11), + "CUT_BUFFER3": Atom(12), + "CUT_BUFFER4": Atom(13), + "CUT_BUFFER5": Atom(14), + "CUT_BUFFER6": Atom(15), + "CUT_BUFFER7": Atom(16), + "DRAWABLE": Atom(17), + "FONT": Atom(18), + "INTEGER": Atom(19), + "PIXMAP": Atom(20), + "POINT": Atom(21), + "RECTANGLE": Atom(22), + "RESOURCE_MANAGER": Atom(23), + "RGB_COLOR_MAP": Atom(24), + "RGB_BEST_MAP": Atom(25), + "RGB_BLUE_MAP": Atom(26), + "RGB_DEFAULT_MAP": Atom(27), + "RGB_GRAY_MAP": Atom(28), + "RGB_GREEN_MAP": Atom(29), + "RGB_RED_MAP": Atom(30), + "STRING": Atom(31), + "VISUALID": Atom(32), + "WINDOW": Atom(33), + "WM_COMMAND": Atom(34), + "WM_HINTS": Atom(35), + "WM_CLIENT_MACHINE": Atom(36), + "WM_ICON_NAME": Atom(37), + "WM_ICON_SIZE": Atom(38), + "WM_NAME": Atom(39), + "WM_NORMAL_HINTS": Atom(40), + "WM_SIZE_HINTS": Atom(41), + "WM_ZOOM_HINTS": Atom(42), + "MIN_SPACE": Atom(43), + "NORM_SPACE": Atom(44), + "MAX_SPACE": Atom(45), + "END_SPACE": Atom(46), + "SUPERSCRIPT_X": Atom(47), + "SUPERSCRIPT_Y": Atom(48), + "SUBSCRIPT_X": Atom(49), + "SUBSCRIPT_Y": Atom(50), + "UNDERLINE_POSITION": Atom(51), + "UNDERLINE_THICKNESS": Atom(52), + "STRIKEOUT_ASCENT": Atom(53), + "STRIKEOUT_DESCENT": Atom(54), + "ITALIC_ANGLE": Atom(55), + "X_HEIGHT": Atom(56), + "QUAD_WIDTH": Atom(57), + "WEIGHT": Atom(58), + "POINT_SIZE": Atom(59), + "RESOLUTION": Atom(60), + "COPYRIGHT": Atom(61), + "NOTICE": Atom(62), + "FONT_NAME": Atom(63), + "FAMILY_NAME": Atom(64), + "FULL_NAME": Atom(65), + "CAP_HEIGHT": Atom(66), + "WM_CLASS": Atom(67), + "WM_TRANSIENT_FOR": Atom(68), +} + +# The atom cache needs to be per-connection. Rather than keying on a (connection, name) tuple, we use a two-level +# cache keyed by the integer address of the underlying XCB connection (see ctypes.addressof in intern_atom). +_ATOM_CACHE: dict[int, dict[str, Atom]] = {} + + +@overload +def intern_atom( + xcb_conn: Connection | _Pointer[Connection], + name: str, + *, + only_if_exists: Literal[False] = False, +) -> Atom: ... +@overload +def intern_atom( + xcb_conn: Connection | _Pointer[Connection], + name: str, + *, + only_if_exists: Literal[True] = True, +) -> Atom | None: ... +@overload +def intern_atom( + xcb_conn: Connection | _Pointer[Connection], + name: str, + *, + only_if_exists: bool, +) -> Atom | None: ... + + +def intern_atom( + xcb_conn: Connection | _Pointer[Connection], + name: str, + *, + only_if_exists: bool = False, +) -> Atom | None: + if name in _PREDEFINED_ATOMS: + return _PREDEFINED_ATOMS[name] + + if isinstance(xcb_conn, _Pointer): + # Dereference the pointer before using the cache. + xcb_conn = xcb_conn.contents + cache_key = addressof(xcb_conn) + if cache_key not in _ATOM_CACHE: + # This can happen if the connection was closed and its cache cleared, but some code still has a reference to + # the connection object. We could re-create the cache entry, but it's safer to just fail instead of silently + # allowing lookups to succeed when they shouldn't. + msg = "Connection to X server is closed" + raise XError(msg) + if name in _ATOM_CACHE[cache_key]: + return _ATOM_CACHE[cache_key][name] + + # Atom names are required to be Latin-1, per the X protocol spec, although anything that's not in the XPCS (a + # subset of ASCII) is vendor-defined. + name_encoded = name.encode("latin_1", errors="strict") + cookie = LIB.xcb.xcb_intern_atom(xcb_conn, 1 if only_if_exists else 0, len(name_encoded), name_encoded) + atom_as_xid = cookie.reply(xcb_conn).atom + if atom_as_xid.value == 0: + if not only_if_exists: + # This shouldn't be possible. We at least need to have a path for the type-checker to be happy, though. + msg = f"X server failed to intern atom '{name}'" + raise XError(msg) + # We don't do negative caching, since any app might intern the atom at any time. + return None + atom = Atom(atom_as_xid.value) + _ATOM_CACHE[cache_key][name] = atom + return atom + + +def get_extension_data( + xcb_conn: Connection | _Pointer[Connection], ext: XcbExtension | _Pointer[XcbExtension] +) -> QueryExtensionReply: + """Get extension data for the given extension. + + Returns the extension data, which includes whether the extension is present + and its opcode information. + """ + reply_p = LIB.xcb.xcb_get_extension_data(xcb_conn, ext) + return reply_p.contents + + +def prefetch_extension_data( + xcb_conn: Connection | _Pointer[Connection], ext: XcbExtension | _Pointer[XcbExtension] +) -> None: + """Prefetch extension data for the given extension. + + This is a performance hint to XCB to fetch the extension data + asynchronously. + """ + LIB.xcb.xcb_prefetch_extension_data(xcb_conn, ext) + + +def generate_id(xcb_conn: Connection | _Pointer[Connection]) -> XID: + """Generate a new unique X resource ID. + + Returns an XID that can be used to create new X resources. + """ + return LIB.xcb.xcb_generate_id(xcb_conn) + + +def get_setup(xcb_conn: Connection | _Pointer[Connection]) -> Setup: + """Get the connection setup information. + + Returns the setup structure containing information about the X server, + including available screens, pixmap formats, etc. + """ + setup_p = LIB.xcb.xcb_get_setup(xcb_conn) + return setup_p.contents + + +# Connection management + + +def initialize() -> None: + LIB.initialize(callbacks=[xcbgen.initialize]) + + +def connect(display: str | bytes | None = None) -> tuple[Connection, int]: + if isinstance(display, str): + display = display.encode("utf-8") + + initialize() + pref_screen_num = c_int() + conn_p = LIB.xcb.xcb_connect(display, pref_screen_num) + + # We still get a connection object even if the connection fails. + conn_err = LIB.xcb.xcb_connection_has_error(conn_p) + if conn_err != 0: + # XCB won't free its connection structures until we disconnect, even in the event of an error. + LIB.xcb.xcb_disconnect(conn_p) + msg = "Cannot connect to display: " + conn_errmsg = XCB_CONN_ERRMSG.get(conn_err) + msg += conn_errmsg or f"error code {conn_err}" + raise XError(msg) + + # Prefetch extension data for all extensions we support to populate XCB's internal cache. + prefetch_extension_data(conn_p, LIB.randr_id) + prefetch_extension_data(conn_p, LIB.render_id) + prefetch_extension_data(conn_p, LIB.shm_id) + prefetch_extension_data(conn_p, LIB.xfixes_id) + + _ATOM_CACHE[addressof(conn_p.contents)] = {} + + return conn_p.contents, pref_screen_num.value + + +def disconnect(xcb_conn: Connection | _Pointer[Connection]) -> None: + if isinstance(xcb_conn, _Pointer): + # Dereference the pointer before using the cache. + xcb_conn = xcb_conn.contents + + # The cache might already be cleared if the connection had an error, or if disconnect was called multiple times. + with contextlib.suppress(KeyError): + del _ATOM_CACHE[addressof(xcb_conn)] + + conn_err = LIB.xcb.xcb_connection_has_error(xcb_conn) + # XCB won't free its connection structures until we disconnect, even in the event of an error. + LIB.xcb.xcb_disconnect(xcb_conn) + if conn_err != 0: + msg = "Connection to X server closed: " + conn_errmsg = XCB_CONN_ERRMSG.get(conn_err) + msg += conn_errmsg or f"error code {conn_err}" + raise XError(msg) diff --git a/src/mss/linux/xcbgen.py b/src/mss/linux/xcbgen.py new file mode 100644 index 00000000..606ad290 --- /dev/null +++ b/src/mss/linux/xcbgen.py @@ -0,0 +1,1107 @@ +# Auto-generated by gen_xcb_to_py.py - do not edit manually. + +# Since many of the generated functions have many parameters, we disable the pylint warning about too many arguments. +# ruff: noqa: PLR0913 + +from __future__ import annotations + +from ctypes import ( + POINTER, + Array, + Structure, + _Pointer, + c_char, + c_int, + c_int16, + c_uint8, + c_uint16, + c_uint32, +) +from enum import IntEnum + +from mss.linux.xcbhelpers import ( + LIB, + XID, + Connection, + VoidCookie, + array_from_xcb, + initialize_xcb_typed_func, + list_from_xcb, +) + +RANDR_MAJOR_VERSION = 1 +RANDR_MINOR_VERSION = 6 +RENDER_MAJOR_VERSION = 0 +RENDER_MINOR_VERSION = 11 +SHM_MAJOR_VERSION = 1 +SHM_MINOR_VERSION = 2 +XFIXES_MAJOR_VERSION = 6 +XFIXES_MINOR_VERSION = 0 + +# Enum classes + + +class RandrConnection(IntEnum): + Connected = 0 + Disconnected = 1 + Unknown = 2 + + +class RandrSetConfig(IntEnum): + Success = 0 + InvalidConfigTime = 1 + InvalidTime = 2 + Failed = 3 + + +class RenderPictType(IntEnum): + Indexed = 0 + Direct = 1 + + +class RenderSubPixel(IntEnum): + Unknown = 0 + HorizontalRGB = 1 + HorizontalBGR = 2 + VerticalRGB = 3 + VerticalBGR = 4 + None_ = 5 + + +class BackingStore(IntEnum): + NotUseful = 0 + WhenMapped = 1 + Always = 2 + + +class ImageFormat(IntEnum): + XYBitmap = 0 + XYPixmap = 1 + ZPixmap = 2 + + +class ImageOrder(IntEnum): + LSBFirst = 0 + MSBFirst = 1 + + +class VisualClass(IntEnum): + StaticGray = 0 + GrayScale = 1 + StaticColor = 2 + PseudoColor = 3 + TrueColor = 4 + DirectColor = 5 + + +# Generated ctypes structures + + +class Drawable(XID): + pass + + +class Keycode(c_uint8): + def __eq__(self, other: object) -> bool: + if isinstance(other, Keycode): + return self.value == other.value + return NotImplemented + + def __hash__(self) -> int: + return hash(self.value) + + +class Format(Structure): + _fields_ = ( + ("depth", c_uint8), + ("bits_per_pixel", c_uint8), + ("scanline_pad", c_uint8), + ("pad0", c_uint8 * 5), + ) + + +class Window(Drawable): + pass + + +class Colormap(XID): + pass + + +class Visualid(c_uint32): + def __eq__(self, other: object) -> bool: + if isinstance(other, Visualid): + return self.value == other.value + return NotImplemented + + def __hash__(self) -> int: + return hash(self.value) + + +class Visualtype(Structure): + _fields_ = ( + ("visual_id", Visualid), + ("class_", c_uint8), + ("bits_per_rgb_value", c_uint8), + ("colormap_entries", c_uint16), + ("red_mask", c_uint32), + ("green_mask", c_uint32), + ("blue_mask", c_uint32), + ("pad0", c_uint8 * 4), + ) + + +class Depth(Structure): + _fields_ = ( + ("depth", c_uint8), + ("pad0", c_uint8 * 1), + ("visuals_len", c_uint16), + ("pad1", c_uint8 * 4), + ) + + +class DepthIterator(Structure): + _fields_ = (("data", POINTER(Depth)), ("rem", c_int), ("index", c_int)) + + +class Screen(Structure): + _fields_ = ( + ("root", Window), + ("default_colormap", Colormap), + ("white_pixel", c_uint32), + ("black_pixel", c_uint32), + ("current_input_masks", c_uint32), + ("width_in_pixels", c_uint16), + ("height_in_pixels", c_uint16), + ("width_in_millimeters", c_uint16), + ("height_in_millimeters", c_uint16), + ("min_installed_maps", c_uint16), + ("max_installed_maps", c_uint16), + ("root_visual", Visualid), + ("backing_stores", c_uint8), + ("save_unders", c_uint8), + ("root_depth", c_uint8), + ("allowed_depths_len", c_uint8), + ) + + +class ScreenIterator(Structure): + _fields_ = (("data", POINTER(Screen)), ("rem", c_int), ("index", c_int)) + + +class Setup(Structure): + _fields_ = ( + ("status", c_uint8), + ("pad0", c_uint8 * 1), + ("protocol_major_version", c_uint16), + ("protocol_minor_version", c_uint16), + ("length", c_uint16), + ("release_number", c_uint32), + ("resource_id_base", c_uint32), + ("resource_id_mask", c_uint32), + ("motion_buffer_size", c_uint32), + ("vendor_len", c_uint16), + ("maximum_request_length", c_uint16), + ("roots_len", c_uint8), + ("pixmap_formats_len", c_uint8), + ("image_byte_order", c_uint8), + ("bitmap_format_bit_order", c_uint8), + ("bitmap_format_scanline_unit", c_uint8), + ("bitmap_format_scanline_pad", c_uint8), + ("min_keycode", Keycode), + ("max_keycode", Keycode), + ("pad1", c_uint8 * 4), + ) + + +class SetupIterator(Structure): + _fields_ = (("data", POINTER(Setup)), ("rem", c_int), ("index", c_int)) + + +class Pixmap(Drawable): + pass + + +class GetGeometryReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("depth", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("root", Window), + ("x", c_int16), + ("y", c_int16), + ("width", c_uint16), + ("height", c_uint16), + ("border_width", c_uint16), + ("pad0", c_uint8 * 10), + ) + + +class GetImageReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("depth", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("visual", Visualid), + ("pad0", c_uint8 * 20), + ) + + +class Atom(XID): + pass + + +class GetPropertyReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("format_", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("type_", Atom), + ("bytes_after", c_uint32), + ("value_len", c_uint32), + ("pad0", c_uint8 * 12), + ) + + +class RandrQueryVersionReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("major_version", c_uint32), + ("minor_version", c_uint32), + ("pad1", c_uint8 * 16), + ) + + +class Timestamp(c_uint32): + def __eq__(self, other: object) -> bool: + if isinstance(other, Timestamp): + return self.value == other.value + return NotImplemented + + def __hash__(self) -> int: + return hash(self.value) + + +class RandrCrtc(XID): + pass + + +class RandrOutput(XID): + pass + + +class RandrModeInfo(Structure): + _fields_ = ( + ("id_", c_uint32), + ("width", c_uint16), + ("height", c_uint16), + ("dot_clock", c_uint32), + ("hsync_start", c_uint16), + ("hsync_end", c_uint16), + ("htotal", c_uint16), + ("hskew", c_uint16), + ("vsync_start", c_uint16), + ("vsync_end", c_uint16), + ("vtotal", c_uint16), + ("name_len", c_uint16), + ("mode_flags", c_uint32), + ) + + +class RandrGetScreenResourcesReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("timestamp", Timestamp), + ("config_timestamp", Timestamp), + ("num_crtcs", c_uint16), + ("num_outputs", c_uint16), + ("num_modes", c_uint16), + ("names_len", c_uint16), + ("pad1", c_uint8 * 8), + ) + + +class RandrGetScreenResourcesCurrentReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("timestamp", Timestamp), + ("config_timestamp", Timestamp), + ("num_crtcs", c_uint16), + ("num_outputs", c_uint16), + ("num_modes", c_uint16), + ("names_len", c_uint16), + ("pad1", c_uint8 * 8), + ) + + +class RandrMode(XID): + pass + + +class RandrGetCrtcInfoReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("status", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("timestamp", Timestamp), + ("x", c_int16), + ("y", c_int16), + ("width", c_uint16), + ("height", c_uint16), + ("mode", RandrMode), + ("rotation", c_uint16), + ("rotations", c_uint16), + ("num_outputs", c_uint16), + ("num_possible_outputs", c_uint16), + ) + + +class RandrGetOutputInfoReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("status", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("timestamp", Timestamp), + ("crtc", RandrCrtc), + ("mm_width", c_uint32), + ("mm_height", c_uint32), + ("connection", c_uint8), + ("subpixel_order", c_uint8), + ("num_crtcs", c_uint16), + ("num_modes", c_uint16), + ("num_preferred", c_uint16), + ("num_clones", c_uint16), + ("name_len", c_uint16), + ) + + +class RandrGetOutputPrimaryReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("output", RandrOutput), + ) + + +class RandrGetOutputPropertyReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("format_", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("type_", Atom), + ("bytes_after", c_uint32), + ("num_items", c_uint32), + ("pad0", c_uint8 * 12), + ) + + +class RandrMonitorInfo(Structure): + _fields_ = ( + ("name", Atom), + ("primary", c_uint8), + ("automatic", c_uint8), + ("nOutput", c_uint16), + ("x", c_int16), + ("y", c_int16), + ("width", c_uint16), + ("height", c_uint16), + ("width_in_millimeters", c_uint32), + ("height_in_millimeters", c_uint32), + ) + + +class RandrMonitorInfoIterator(Structure): + _fields_ = (("data", POINTER(RandrMonitorInfo)), ("rem", c_int), ("index", c_int)) + + +class RandrGetMonitorsReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("timestamp", Timestamp), + ("nMonitors", c_uint32), + ("nOutputs", c_uint32), + ("pad1", c_uint8 * 12), + ) + + +class RenderQueryVersionReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("major_version", c_uint32), + ("minor_version", c_uint32), + ("pad1", c_uint8 * 16), + ) + + +class RenderPictformat(XID): + pass + + +class RenderDirectformat(Structure): + _fields_ = ( + ("red_shift", c_uint16), + ("red_mask", c_uint16), + ("green_shift", c_uint16), + ("green_mask", c_uint16), + ("blue_shift", c_uint16), + ("blue_mask", c_uint16), + ("alpha_shift", c_uint16), + ("alpha_mask", c_uint16), + ) + + +class RenderPictforminfo(Structure): + _fields_ = ( + ("id_", RenderPictformat), + ("type_", c_uint8), + ("depth", c_uint8), + ("pad0", c_uint8 * 2), + ("direct", RenderDirectformat), + ("colormap", Colormap), + ) + + +class RenderPictvisual(Structure): + _fields_ = ( + ("visual", Visualid), + ("format_", RenderPictformat), + ) + + +class RenderPictdepth(Structure): + _fields_ = ( + ("depth", c_uint8), + ("pad0", c_uint8 * 1), + ("num_visuals", c_uint16), + ("pad1", c_uint8 * 4), + ) + + +class RenderPictdepthIterator(Structure): + _fields_ = (("data", POINTER(RenderPictdepth)), ("rem", c_int), ("index", c_int)) + + +class RenderPictscreen(Structure): + _fields_ = ( + ("num_depths", c_uint32), + ("fallback", RenderPictformat), + ) + + +class RenderPictscreenIterator(Structure): + _fields_ = (("data", POINTER(RenderPictscreen)), ("rem", c_int), ("index", c_int)) + + +class RenderQueryPictFormatsReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("num_formats", c_uint32), + ("num_screens", c_uint32), + ("num_depths", c_uint32), + ("num_visuals", c_uint32), + ("num_subpixel", c_uint32), + ("pad1", c_uint8 * 4), + ) + + +class ShmQueryVersionReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("shared_pixmaps", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("major_version", c_uint16), + ("minor_version", c_uint16), + ("uid", c_uint16), + ("gid", c_uint16), + ("pixmap_format", c_uint8), + ("pad0", c_uint8 * 15), + ) + + +class ShmSeg(XID): + pass + + +class ShmGetImageReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("depth", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("visual", Visualid), + ("size", c_uint32), + ("pad0", c_uint8 * 16), + ) + + +class ShmCreateSegmentReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("nfd", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("pad0", c_uint8 * 24), + ) + + +class XfixesQueryVersionReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("major_version", c_uint32), + ("minor_version", c_uint32), + ("pad1", c_uint8 * 16), + ) + + +class XfixesGetCursorImageReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("x", c_int16), + ("y", c_int16), + ("width", c_uint16), + ("height", c_uint16), + ("xhot", c_uint16), + ("yhot", c_uint16), + ("cursor_serial", c_uint32), + ("pad1", c_uint8 * 8), + ) + + +def depth_visuals(r: Depth) -> Array[Visualtype]: + return array_from_xcb(LIB.xcb.xcb_depth_visuals, LIB.xcb.xcb_depth_visuals_length, r) + + +def screen_allowed_depths(r: Screen) -> list[Depth]: + return list_from_xcb(LIB.xcb.xcb_screen_allowed_depths_iterator, LIB.xcb.xcb_depth_next, r) + + +def setup_vendor(r: Setup) -> Array[c_char]: + return array_from_xcb(LIB.xcb.xcb_setup_vendor, LIB.xcb.xcb_setup_vendor_length, r) + + +def setup_pixmap_formats(r: Setup) -> Array[Format]: + return array_from_xcb(LIB.xcb.xcb_setup_pixmap_formats, LIB.xcb.xcb_setup_pixmap_formats_length, r) + + +def setup_roots(r: Setup) -> list[Screen]: + return list_from_xcb(LIB.xcb.xcb_setup_roots_iterator, LIB.xcb.xcb_screen_next, r) + + +def get_image_data(r: GetImageReply) -> Array[c_uint8]: + return array_from_xcb(LIB.xcb.xcb_get_image_data, LIB.xcb.xcb_get_image_data_length, r) + + +def get_property_value(r: GetPropertyReply) -> Array[c_char]: + return array_from_xcb(LIB.xcb.xcb_get_property_value, LIB.xcb.xcb_get_property_value_length, r) + + +def randr_get_screen_resources_crtcs(r: RandrGetScreenResourcesReply) -> Array[RandrCrtc]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_crtcs, LIB.randr.xcb_randr_get_screen_resources_crtcs_length, r + ) + + +def randr_get_screen_resources_outputs(r: RandrGetScreenResourcesReply) -> Array[RandrOutput]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_outputs, LIB.randr.xcb_randr_get_screen_resources_outputs_length, r + ) + + +def randr_get_screen_resources_modes(r: RandrGetScreenResourcesReply) -> Array[RandrModeInfo]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_modes, LIB.randr.xcb_randr_get_screen_resources_modes_length, r + ) + + +def randr_get_screen_resources_names(r: RandrGetScreenResourcesReply) -> Array[c_uint8]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_names, LIB.randr.xcb_randr_get_screen_resources_names_length, r + ) + + +def randr_get_screen_resources_current_crtcs(r: RandrGetScreenResourcesCurrentReply) -> Array[RandrCrtc]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_current_crtcs, + LIB.randr.xcb_randr_get_screen_resources_current_crtcs_length, + r, + ) + + +def randr_get_screen_resources_current_outputs(r: RandrGetScreenResourcesCurrentReply) -> Array[RandrOutput]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_current_outputs, + LIB.randr.xcb_randr_get_screen_resources_current_outputs_length, + r, + ) + + +def randr_get_screen_resources_current_modes(r: RandrGetScreenResourcesCurrentReply) -> Array[RandrModeInfo]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_current_modes, + LIB.randr.xcb_randr_get_screen_resources_current_modes_length, + r, + ) + + +def randr_get_screen_resources_current_names(r: RandrGetScreenResourcesCurrentReply) -> Array[c_uint8]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_current_names, + LIB.randr.xcb_randr_get_screen_resources_current_names_length, + r, + ) + + +def randr_get_crtc_info_outputs(r: RandrGetCrtcInfoReply) -> Array[RandrOutput]: + return array_from_xcb( + LIB.randr.xcb_randr_get_crtc_info_outputs, LIB.randr.xcb_randr_get_crtc_info_outputs_length, r + ) + + +def randr_get_crtc_info_possible(r: RandrGetCrtcInfoReply) -> Array[RandrOutput]: + return array_from_xcb( + LIB.randr.xcb_randr_get_crtc_info_possible, LIB.randr.xcb_randr_get_crtc_info_possible_length, r + ) + + +def randr_get_output_info_crtcs(r: RandrGetOutputInfoReply) -> Array[RandrCrtc]: + return array_from_xcb( + LIB.randr.xcb_randr_get_output_info_crtcs, LIB.randr.xcb_randr_get_output_info_crtcs_length, r + ) + + +def randr_get_output_info_modes(r: RandrGetOutputInfoReply) -> Array[RandrMode]: + return array_from_xcb( + LIB.randr.xcb_randr_get_output_info_modes, LIB.randr.xcb_randr_get_output_info_modes_length, r + ) + + +def randr_get_output_info_clones(r: RandrGetOutputInfoReply) -> Array[RandrOutput]: + return array_from_xcb( + LIB.randr.xcb_randr_get_output_info_clones, LIB.randr.xcb_randr_get_output_info_clones_length, r + ) + + +def randr_get_output_info_name(r: RandrGetOutputInfoReply) -> Array[c_uint8]: + return array_from_xcb(LIB.randr.xcb_randr_get_output_info_name, LIB.randr.xcb_randr_get_output_info_name_length, r) + + +def randr_get_output_property_data(r: RandrGetOutputPropertyReply) -> Array[c_uint8]: + return array_from_xcb( + LIB.randr.xcb_randr_get_output_property_data, LIB.randr.xcb_randr_get_output_property_data_length, r + ) + + +def randr_monitor_info_outputs(r: RandrMonitorInfo) -> Array[RandrOutput]: + return array_from_xcb(LIB.randr.xcb_randr_monitor_info_outputs, LIB.randr.xcb_randr_monitor_info_outputs_length, r) + + +def randr_get_monitors_monitors(r: RandrGetMonitorsReply) -> list[RandrMonitorInfo]: + return list_from_xcb(LIB.randr.xcb_randr_get_monitors_monitors_iterator, LIB.randr.xcb_randr_monitor_info_next, r) + + +def render_pictdepth_visuals(r: RenderPictdepth) -> Array[RenderPictvisual]: + return array_from_xcb(LIB.render.xcb_render_pictdepth_visuals, LIB.render.xcb_render_pictdepth_visuals_length, r) + + +def render_pictscreen_depths(r: RenderPictscreen) -> list[RenderPictdepth]: + return list_from_xcb(LIB.render.xcb_render_pictscreen_depths_iterator, LIB.render.xcb_render_pictdepth_next, r) + + +def render_query_pict_formats_formats(r: RenderQueryPictFormatsReply) -> Array[RenderPictforminfo]: + return array_from_xcb( + LIB.render.xcb_render_query_pict_formats_formats, LIB.render.xcb_render_query_pict_formats_formats_length, r + ) + + +def render_query_pict_formats_screens(r: RenderQueryPictFormatsReply) -> list[RenderPictscreen]: + return list_from_xcb( + LIB.render.xcb_render_query_pict_formats_screens_iterator, LIB.render.xcb_render_pictscreen_next, r + ) + + +def render_query_pict_formats_subpixels(r: RenderQueryPictFormatsReply) -> Array[c_uint32]: + return array_from_xcb( + LIB.render.xcb_render_query_pict_formats_subpixels, LIB.render.xcb_render_query_pict_formats_subpixels_length, r + ) + + +def xfixes_get_cursor_image_cursor_image(r: XfixesGetCursorImageReply) -> Array[c_uint32]: + return array_from_xcb( + LIB.xfixes.xcb_xfixes_get_cursor_image_cursor_image, + LIB.xfixes.xcb_xfixes_get_cursor_image_cursor_image_length, + r, + ) + + +def shm_create_segment_reply_fds(c: Connection | _Pointer[Connection], r: ShmCreateSegmentReply) -> _Pointer[c_int]: + return LIB.shm.xcb_shm_create_segment_reply_fds(c, r) + + +def get_geometry(c: Connection, drawable: Drawable) -> GetGeometryReply: + return LIB.xcb.xcb_get_geometry(c, drawable).reply(c) + + +def get_image( + c: Connection, + format_: c_uint8 | int, + drawable: Drawable, + x: c_int16 | int, + y: c_int16 | int, + width: c_uint16 | int, + height: c_uint16 | int, + plane_mask: c_uint32 | int, +) -> GetImageReply: + return LIB.xcb.xcb_get_image(c, format_, drawable, x, y, width, height, plane_mask).reply(c) + + +def get_property( + c: Connection, + delete: c_uint8 | int, + window: Window, + property_: Atom, + type_: Atom, + long_offset: c_uint32 | int, + long_length: c_uint32 | int, +) -> GetPropertyReply: + return LIB.xcb.xcb_get_property(c, delete, window, property_, type_, long_offset, long_length).reply(c) + + +def no_operation(c: Connection) -> None: + return LIB.xcb.xcb_no_operation_checked(c).check(c) + + +def randr_query_version( + c: Connection, major_version: c_uint32 | int, minor_version: c_uint32 | int +) -> RandrQueryVersionReply: + return LIB.randr.xcb_randr_query_version(c, major_version, minor_version).reply(c) + + +def randr_get_screen_resources(c: Connection, window: Window) -> RandrGetScreenResourcesReply: + return LIB.randr.xcb_randr_get_screen_resources(c, window).reply(c) + + +def randr_get_screen_resources_current(c: Connection, window: Window) -> RandrGetScreenResourcesCurrentReply: + return LIB.randr.xcb_randr_get_screen_resources_current(c, window).reply(c) + + +def randr_get_crtc_info(c: Connection, crtc: RandrCrtc, config_timestamp: Timestamp) -> RandrGetCrtcInfoReply: + return LIB.randr.xcb_randr_get_crtc_info(c, crtc, config_timestamp).reply(c) + + +def randr_get_output_info(c: Connection, output: RandrOutput, config_timestamp: Timestamp) -> RandrGetOutputInfoReply: + return LIB.randr.xcb_randr_get_output_info(c, output, config_timestamp).reply(c) + + +def randr_get_output_primary(c: Connection, window: Window) -> RandrGetOutputPrimaryReply: + return LIB.randr.xcb_randr_get_output_primary(c, window).reply(c) + + +def randr_get_output_property( + c: Connection, + output: RandrOutput, + property_: Atom, + type_: Atom, + long_offset: c_uint32 | int, + long_length: c_uint32 | int, + delete: c_uint8 | int, + pending: c_uint8 | int, +) -> RandrGetOutputPropertyReply: + return LIB.randr.xcb_randr_get_output_property( + c, output, property_, type_, long_offset, long_length, delete, pending + ).reply(c) + + +def randr_get_monitors(c: Connection, window: Window, get_active: c_uint8 | int) -> RandrGetMonitorsReply: + return LIB.randr.xcb_randr_get_monitors(c, window, get_active).reply(c) + + +def render_query_version( + c: Connection, client_major_version: c_uint32 | int, client_minor_version: c_uint32 | int +) -> RenderQueryVersionReply: + return LIB.render.xcb_render_query_version(c, client_major_version, client_minor_version).reply(c) + + +def render_query_pict_formats(c: Connection) -> RenderQueryPictFormatsReply: + return LIB.render.xcb_render_query_pict_formats(c).reply(c) + + +def shm_query_version(c: Connection) -> ShmQueryVersionReply: + return LIB.shm.xcb_shm_query_version(c).reply(c) + + +def shm_get_image( + c: Connection, + drawable: Drawable, + x: c_int16 | int, + y: c_int16 | int, + width: c_uint16 | int, + height: c_uint16 | int, + plane_mask: c_uint32 | int, + format_: c_uint8 | int, + shmseg: ShmSeg, + offset: c_uint32 | int, +) -> ShmGetImageReply: + return LIB.shm.xcb_shm_get_image(c, drawable, x, y, width, height, plane_mask, format_, shmseg, offset).reply(c) + + +def shm_attach_fd(c: Connection, shmseg: ShmSeg, shm_fd: c_int | int, read_only: c_uint8 | int) -> None: + return LIB.shm.xcb_shm_attach_fd_checked(c, shmseg, shm_fd, read_only).check(c) + + +def shm_create_segment( + c: Connection, shmseg: ShmSeg, size: c_uint32 | int, read_only: c_uint8 | int +) -> ShmCreateSegmentReply: + return LIB.shm.xcb_shm_create_segment(c, shmseg, size, read_only).reply(c) + + +def shm_detach(c: Connection, shmseg: ShmSeg) -> None: + return LIB.shm.xcb_shm_detach_checked(c, shmseg).check(c) + + +def xfixes_query_version( + c: Connection, client_major_version: c_uint32 | int, client_minor_version: c_uint32 | int +) -> XfixesQueryVersionReply: + return LIB.xfixes.xcb_xfixes_query_version(c, client_major_version, client_minor_version).reply(c) + + +def xfixes_get_cursor_image(c: Connection) -> XfixesGetCursorImageReply: + return LIB.xfixes.xcb_xfixes_get_cursor_image(c).reply(c) + + +def initialize() -> None: # noqa: PLR0915 + LIB.xcb.xcb_depth_next.argtypes = (POINTER(DepthIterator),) + LIB.xcb.xcb_depth_next.restype = None + LIB.xcb.xcb_screen_next.argtypes = (POINTER(ScreenIterator),) + LIB.xcb.xcb_screen_next.restype = None + LIB.xcb.xcb_setup_next.argtypes = (POINTER(SetupIterator),) + LIB.xcb.xcb_setup_next.restype = None + LIB.randr.xcb_randr_monitor_info_next.argtypes = (POINTER(RandrMonitorInfoIterator),) + LIB.randr.xcb_randr_monitor_info_next.restype = None + LIB.render.xcb_render_pictdepth_next.argtypes = (POINTER(RenderPictdepthIterator),) + LIB.render.xcb_render_pictdepth_next.restype = None + LIB.render.xcb_render_pictscreen_next.argtypes = (POINTER(RenderPictscreenIterator),) + LIB.render.xcb_render_pictscreen_next.restype = None + LIB.xcb.xcb_depth_visuals.argtypes = (POINTER(Depth),) + LIB.xcb.xcb_depth_visuals.restype = POINTER(Visualtype) + LIB.xcb.xcb_depth_visuals_length.argtypes = (POINTER(Depth),) + LIB.xcb.xcb_depth_visuals_length.restype = c_int + LIB.xcb.xcb_screen_allowed_depths_iterator.argtypes = (POINTER(Screen),) + LIB.xcb.xcb_screen_allowed_depths_iterator.restype = DepthIterator + LIB.xcb.xcb_setup_vendor.argtypes = (POINTER(Setup),) + LIB.xcb.xcb_setup_vendor.restype = POINTER(c_char) + LIB.xcb.xcb_setup_vendor_length.argtypes = (POINTER(Setup),) + LIB.xcb.xcb_setup_vendor_length.restype = c_int + LIB.xcb.xcb_setup_pixmap_formats.argtypes = (POINTER(Setup),) + LIB.xcb.xcb_setup_pixmap_formats.restype = POINTER(Format) + LIB.xcb.xcb_setup_pixmap_formats_length.argtypes = (POINTER(Setup),) + LIB.xcb.xcb_setup_pixmap_formats_length.restype = c_int + LIB.xcb.xcb_setup_roots_iterator.argtypes = (POINTER(Setup),) + LIB.xcb.xcb_setup_roots_iterator.restype = ScreenIterator + LIB.xcb.xcb_get_image_data.argtypes = (POINTER(GetImageReply),) + LIB.xcb.xcb_get_image_data.restype = POINTER(c_uint8) + LIB.xcb.xcb_get_image_data_length.argtypes = (POINTER(GetImageReply),) + LIB.xcb.xcb_get_image_data_length.restype = c_int + LIB.xcb.xcb_get_property_value.argtypes = (POINTER(GetPropertyReply),) + LIB.xcb.xcb_get_property_value.restype = POINTER(c_char) + LIB.xcb.xcb_get_property_value_length.argtypes = (POINTER(GetPropertyReply),) + LIB.xcb.xcb_get_property_value_length.restype = c_int + LIB.randr.xcb_randr_get_screen_resources_crtcs.argtypes = (POINTER(RandrGetScreenResourcesReply),) + LIB.randr.xcb_randr_get_screen_resources_crtcs.restype = POINTER(RandrCrtc) + LIB.randr.xcb_randr_get_screen_resources_crtcs_length.argtypes = (POINTER(RandrGetScreenResourcesReply),) + LIB.randr.xcb_randr_get_screen_resources_crtcs_length.restype = c_int + LIB.randr.xcb_randr_get_screen_resources_outputs.argtypes = (POINTER(RandrGetScreenResourcesReply),) + LIB.randr.xcb_randr_get_screen_resources_outputs.restype = POINTER(RandrOutput) + LIB.randr.xcb_randr_get_screen_resources_outputs_length.argtypes = (POINTER(RandrGetScreenResourcesReply),) + LIB.randr.xcb_randr_get_screen_resources_outputs_length.restype = c_int + LIB.randr.xcb_randr_get_screen_resources_modes.argtypes = (POINTER(RandrGetScreenResourcesReply),) + LIB.randr.xcb_randr_get_screen_resources_modes.restype = POINTER(RandrModeInfo) + LIB.randr.xcb_randr_get_screen_resources_modes_length.argtypes = (POINTER(RandrGetScreenResourcesReply),) + LIB.randr.xcb_randr_get_screen_resources_modes_length.restype = c_int + LIB.randr.xcb_randr_get_screen_resources_names.argtypes = (POINTER(RandrGetScreenResourcesReply),) + LIB.randr.xcb_randr_get_screen_resources_names.restype = POINTER(c_uint8) + LIB.randr.xcb_randr_get_screen_resources_names_length.argtypes = (POINTER(RandrGetScreenResourcesReply),) + LIB.randr.xcb_randr_get_screen_resources_names_length.restype = c_int + LIB.randr.xcb_randr_get_screen_resources_current_crtcs.argtypes = (POINTER(RandrGetScreenResourcesCurrentReply),) + LIB.randr.xcb_randr_get_screen_resources_current_crtcs.restype = POINTER(RandrCrtc) + LIB.randr.xcb_randr_get_screen_resources_current_crtcs_length.argtypes = ( + POINTER(RandrGetScreenResourcesCurrentReply), + ) + LIB.randr.xcb_randr_get_screen_resources_current_crtcs_length.restype = c_int + LIB.randr.xcb_randr_get_screen_resources_current_outputs.argtypes = (POINTER(RandrGetScreenResourcesCurrentReply),) + LIB.randr.xcb_randr_get_screen_resources_current_outputs.restype = POINTER(RandrOutput) + LIB.randr.xcb_randr_get_screen_resources_current_outputs_length.argtypes = ( + POINTER(RandrGetScreenResourcesCurrentReply), + ) + LIB.randr.xcb_randr_get_screen_resources_current_outputs_length.restype = c_int + LIB.randr.xcb_randr_get_screen_resources_current_modes.argtypes = (POINTER(RandrGetScreenResourcesCurrentReply),) + LIB.randr.xcb_randr_get_screen_resources_current_modes.restype = POINTER(RandrModeInfo) + LIB.randr.xcb_randr_get_screen_resources_current_modes_length.argtypes = ( + POINTER(RandrGetScreenResourcesCurrentReply), + ) + LIB.randr.xcb_randr_get_screen_resources_current_modes_length.restype = c_int + LIB.randr.xcb_randr_get_screen_resources_current_names.argtypes = (POINTER(RandrGetScreenResourcesCurrentReply),) + LIB.randr.xcb_randr_get_screen_resources_current_names.restype = POINTER(c_uint8) + LIB.randr.xcb_randr_get_screen_resources_current_names_length.argtypes = ( + POINTER(RandrGetScreenResourcesCurrentReply), + ) + LIB.randr.xcb_randr_get_screen_resources_current_names_length.restype = c_int + LIB.randr.xcb_randr_get_crtc_info_outputs.argtypes = (POINTER(RandrGetCrtcInfoReply),) + LIB.randr.xcb_randr_get_crtc_info_outputs.restype = POINTER(RandrOutput) + LIB.randr.xcb_randr_get_crtc_info_outputs_length.argtypes = (POINTER(RandrGetCrtcInfoReply),) + LIB.randr.xcb_randr_get_crtc_info_outputs_length.restype = c_int + LIB.randr.xcb_randr_get_crtc_info_possible.argtypes = (POINTER(RandrGetCrtcInfoReply),) + LIB.randr.xcb_randr_get_crtc_info_possible.restype = POINTER(RandrOutput) + LIB.randr.xcb_randr_get_crtc_info_possible_length.argtypes = (POINTER(RandrGetCrtcInfoReply),) + LIB.randr.xcb_randr_get_crtc_info_possible_length.restype = c_int + LIB.randr.xcb_randr_get_output_info_crtcs.argtypes = (POINTER(RandrGetOutputInfoReply),) + LIB.randr.xcb_randr_get_output_info_crtcs.restype = POINTER(RandrCrtc) + LIB.randr.xcb_randr_get_output_info_crtcs_length.argtypes = (POINTER(RandrGetOutputInfoReply),) + LIB.randr.xcb_randr_get_output_info_crtcs_length.restype = c_int + LIB.randr.xcb_randr_get_output_info_modes.argtypes = (POINTER(RandrGetOutputInfoReply),) + LIB.randr.xcb_randr_get_output_info_modes.restype = POINTER(RandrMode) + LIB.randr.xcb_randr_get_output_info_modes_length.argtypes = (POINTER(RandrGetOutputInfoReply),) + LIB.randr.xcb_randr_get_output_info_modes_length.restype = c_int + LIB.randr.xcb_randr_get_output_info_clones.argtypes = (POINTER(RandrGetOutputInfoReply),) + LIB.randr.xcb_randr_get_output_info_clones.restype = POINTER(RandrOutput) + LIB.randr.xcb_randr_get_output_info_clones_length.argtypes = (POINTER(RandrGetOutputInfoReply),) + LIB.randr.xcb_randr_get_output_info_clones_length.restype = c_int + LIB.randr.xcb_randr_get_output_info_name.argtypes = (POINTER(RandrGetOutputInfoReply),) + LIB.randr.xcb_randr_get_output_info_name.restype = POINTER(c_uint8) + LIB.randr.xcb_randr_get_output_info_name_length.argtypes = (POINTER(RandrGetOutputInfoReply),) + LIB.randr.xcb_randr_get_output_info_name_length.restype = c_int + LIB.randr.xcb_randr_get_output_property_data.argtypes = (POINTER(RandrGetOutputPropertyReply),) + LIB.randr.xcb_randr_get_output_property_data.restype = POINTER(c_uint8) + LIB.randr.xcb_randr_get_output_property_data_length.argtypes = (POINTER(RandrGetOutputPropertyReply),) + LIB.randr.xcb_randr_get_output_property_data_length.restype = c_int + LIB.randr.xcb_randr_monitor_info_outputs.argtypes = (POINTER(RandrMonitorInfo),) + LIB.randr.xcb_randr_monitor_info_outputs.restype = POINTER(RandrOutput) + LIB.randr.xcb_randr_monitor_info_outputs_length.argtypes = (POINTER(RandrMonitorInfo),) + LIB.randr.xcb_randr_monitor_info_outputs_length.restype = c_int + LIB.randr.xcb_randr_get_monitors_monitors_iterator.argtypes = (POINTER(RandrGetMonitorsReply),) + LIB.randr.xcb_randr_get_monitors_monitors_iterator.restype = RandrMonitorInfoIterator + LIB.render.xcb_render_pictdepth_visuals.argtypes = (POINTER(RenderPictdepth),) + LIB.render.xcb_render_pictdepth_visuals.restype = POINTER(RenderPictvisual) + LIB.render.xcb_render_pictdepth_visuals_length.argtypes = (POINTER(RenderPictdepth),) + LIB.render.xcb_render_pictdepth_visuals_length.restype = c_int + LIB.render.xcb_render_pictscreen_depths_iterator.argtypes = (POINTER(RenderPictscreen),) + LIB.render.xcb_render_pictscreen_depths_iterator.restype = RenderPictdepthIterator + LIB.render.xcb_render_query_pict_formats_formats.argtypes = (POINTER(RenderQueryPictFormatsReply),) + LIB.render.xcb_render_query_pict_formats_formats.restype = POINTER(RenderPictforminfo) + LIB.render.xcb_render_query_pict_formats_formats_length.argtypes = (POINTER(RenderQueryPictFormatsReply),) + LIB.render.xcb_render_query_pict_formats_formats_length.restype = c_int + LIB.render.xcb_render_query_pict_formats_screens_iterator.argtypes = (POINTER(RenderQueryPictFormatsReply),) + LIB.render.xcb_render_query_pict_formats_screens_iterator.restype = RenderPictscreenIterator + LIB.render.xcb_render_query_pict_formats_subpixels.argtypes = (POINTER(RenderQueryPictFormatsReply),) + LIB.render.xcb_render_query_pict_formats_subpixels.restype = POINTER(c_uint32) + LIB.render.xcb_render_query_pict_formats_subpixels_length.argtypes = (POINTER(RenderQueryPictFormatsReply),) + LIB.render.xcb_render_query_pict_formats_subpixels_length.restype = c_int + LIB.xfixes.xcb_xfixes_get_cursor_image_cursor_image.argtypes = (POINTER(XfixesGetCursorImageReply),) + LIB.xfixes.xcb_xfixes_get_cursor_image_cursor_image.restype = POINTER(c_uint32) + LIB.xfixes.xcb_xfixes_get_cursor_image_cursor_image_length.argtypes = (POINTER(XfixesGetCursorImageReply),) + LIB.xfixes.xcb_xfixes_get_cursor_image_cursor_image_length.restype = c_int + LIB.shm.xcb_shm_create_segment_reply_fds.argtypes = (POINTER(Connection), POINTER(ShmCreateSegmentReply)) + LIB.shm.xcb_shm_create_segment_reply_fds.restype = POINTER(c_int) + initialize_xcb_typed_func(LIB.xcb, "xcb_get_geometry", [POINTER(Connection), Drawable], GetGeometryReply) + initialize_xcb_typed_func( + LIB.xcb, + "xcb_get_image", + [POINTER(Connection), c_uint8, Drawable, c_int16, c_int16, c_uint16, c_uint16, c_uint32], + GetImageReply, + ) + initialize_xcb_typed_func( + LIB.xcb, + "xcb_get_property", + [POINTER(Connection), c_uint8, Window, Atom, Atom, c_uint32, c_uint32], + GetPropertyReply, + ) + LIB.xcb.xcb_no_operation_checked.argtypes = (POINTER(Connection),) + LIB.xcb.xcb_no_operation_checked.restype = VoidCookie + initialize_xcb_typed_func( + LIB.randr, "xcb_randr_query_version", [POINTER(Connection), c_uint32, c_uint32], RandrQueryVersionReply + ) + initialize_xcb_typed_func( + LIB.randr, "xcb_randr_get_screen_resources", [POINTER(Connection), Window], RandrGetScreenResourcesReply + ) + initialize_xcb_typed_func( + LIB.randr, + "xcb_randr_get_screen_resources_current", + [POINTER(Connection), Window], + RandrGetScreenResourcesCurrentReply, + ) + initialize_xcb_typed_func( + LIB.randr, "xcb_randr_get_crtc_info", [POINTER(Connection), RandrCrtc, Timestamp], RandrGetCrtcInfoReply + ) + initialize_xcb_typed_func( + LIB.randr, "xcb_randr_get_output_info", [POINTER(Connection), RandrOutput, Timestamp], RandrGetOutputInfoReply + ) + initialize_xcb_typed_func( + LIB.randr, "xcb_randr_get_output_primary", [POINTER(Connection), Window], RandrGetOutputPrimaryReply + ) + initialize_xcb_typed_func( + LIB.randr, + "xcb_randr_get_output_property", + [POINTER(Connection), RandrOutput, Atom, Atom, c_uint32, c_uint32, c_uint8, c_uint8], + RandrGetOutputPropertyReply, + ) + initialize_xcb_typed_func( + LIB.randr, "xcb_randr_get_monitors", [POINTER(Connection), Window, c_uint8], RandrGetMonitorsReply + ) + initialize_xcb_typed_func( + LIB.render, "xcb_render_query_version", [POINTER(Connection), c_uint32, c_uint32], RenderQueryVersionReply + ) + initialize_xcb_typed_func( + LIB.render, "xcb_render_query_pict_formats", [POINTER(Connection)], RenderQueryPictFormatsReply + ) + initialize_xcb_typed_func(LIB.shm, "xcb_shm_query_version", [POINTER(Connection)], ShmQueryVersionReply) + initialize_xcb_typed_func( + LIB.shm, + "xcb_shm_get_image", + [POINTER(Connection), Drawable, c_int16, c_int16, c_uint16, c_uint16, c_uint32, c_uint8, ShmSeg, c_uint32], + ShmGetImageReply, + ) + LIB.shm.xcb_shm_attach_fd_checked.argtypes = (POINTER(Connection), ShmSeg, c_int, c_uint8) + LIB.shm.xcb_shm_attach_fd_checked.restype = VoidCookie + initialize_xcb_typed_func( + LIB.shm, "xcb_shm_create_segment", [POINTER(Connection), ShmSeg, c_uint32, c_uint8], ShmCreateSegmentReply + ) + LIB.shm.xcb_shm_detach_checked.argtypes = (POINTER(Connection), ShmSeg) + LIB.shm.xcb_shm_detach_checked.restype = VoidCookie + initialize_xcb_typed_func( + LIB.xfixes, "xcb_xfixes_query_version", [POINTER(Connection), c_uint32, c_uint32], XfixesQueryVersionReply + ) + initialize_xcb_typed_func( + LIB.xfixes, "xcb_xfixes_get_cursor_image", [POINTER(Connection)], XfixesGetCursorImageReply + ) diff --git a/src/mss/linux/xcbhelpers.py b/src/mss/linux/xcbhelpers.py new file mode 100644 index 00000000..fada044c --- /dev/null +++ b/src/mss/linux/xcbhelpers.py @@ -0,0 +1,613 @@ +from __future__ import annotations + +import ctypes.util +from contextlib import suppress +from copy import copy +from ctypes import ( + CDLL, + POINTER, + Array, + Structure, + _Pointer, + addressof, + c_char, + c_char_p, + c_int, + c_uint, + c_uint8, + c_uint16, + c_uint32, + c_void_p, + cast, + cdll, +) +from threading import RLock +from typing import TYPE_CHECKING +from weakref import finalize + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + from typing import Any + +from mss.exception import ScreenShotError + +# A quick refresher on why this module spends so much effort on object lifetimes, and how the pieces fit together: +# +# 1. Shape of XCB replies. +# Each reply that comes back from libxcb is one contiguous allocation that looks like: +# [fixed-size header][optional padding][embedded arrays/lists] +# The protocol spec describes where those trailing lists live, but callers are not expected to compute offsets by +# hand. Instead, XCB exposes helper functions such as `xcb_setup_pixmap_formats` (returns pointer + length for a +# fixed-size array) or iterator factories for nested variable-length data. As long as the original reply is still +# allocated, all of the derived pointers remain valid. +# +# 2. What ctypes does (and does not) track automatically. +# When user code reads `my_struct.foo`, ctypes returns another ctypes object that still refers to memory owned by +# `my_struct`; it does not copy the value. To keep that relationship alive, ctypes silently sets `_b_base_` on the +# derived object so the garbage collector knows that `my_struct` must stay around. This mechanism only works when +# ctypes itself materializes the derived object. +# +# 3. Why XCB accessors break that safety net. +# The XCB helpers we need - `xcb_setup_pixmap_formats`, `xcb_randr_get_screen_resources_crtcs`, etc. - return raw C +# pointers. ctypes happily converts them to Python objects, but because the conversion went through a plain C +# call, `_b_base_` never gets filled in. The GC no longer realizes that the derived array depends on the reply, so +# once every direct reference to the reply drops, libc is free to `free()` the allocation. Any later access +# through the derived pointer becomes undefined behaviour. +# +# 4. How this module keeps everything safe. +# After every call into an XCB accessor we immediately call `depends_on(child, parent)`. That helper installs a +# finalizer on `child` whose only job is to keep a reference to `parent`. No extra work is performed; the callback +# holding the reference is enough to keep the reply alive until the child objects disappear. Separately, when we +# first receive the reply, we register another finalizer that hands the pointer back to libc once *all* dependants +# have been collected. As a result, higher-level code can treat these helper functions just like the XCB C API: +# grab the array you need, keep it as long as you like, and trust that it stays valid. + + +def depends_on(subobject: Any, superobject: Any) -> None: + """Make sure that superobject is not GC'd before subobject. + + In XCB, a structure often is allocated with additional trailing + data following it, with special accessors to get pointers to that + extra data. + + In ctypes, if you access a structure field, a pointer value, etc., + then the outer object won't be garbage collected until after the + inner object. (This uses the ctypes _b_base_ mechanism.) + + However, when using the XCB accessor functions, you don't get that + guarantee automatically. Once all references to the outer + structure have dropped, then we will free the memory for it (the + response structures XCB returns have to be freed by us), including + the trailing data. If there are live references to the trailing + data, then those will become invalid. + + To prevent this, we use depends_on to make sure that the + outer structure is not released before all the references to the + inner objects have been cleared. + """ + # The implementation is quite simple. We create a finalizer on the inner object, with a callback that references + # the outer object. That ensures that there are live references to the outer object until the references to the + # inner object have been gc'd. We can't just create a ref, though; it seems that their callbacks will only run if + # the ref itself is still referenced. We need the extra machinery that finalize provides, which uses an internal + # registry to keep the refs alive. + finalize(subobject, id, superobject) + + +#### XCB basic structures + + +class Connection(Structure): + pass # Opaque + + +class XID(c_uint32): + def __eq__(self, other: object) -> bool: + return self.value == other.value if isinstance(other, XID) else NotImplemented + + def __hash__(self) -> int: + return hash(self.value) + + +class GenericErrorStructure(Structure): + # The XCB name in C is xcb_generic_error. It is named differently here to make it clear that this is not an + # exception class, since in Python, those traditionally end in ...Error. + _fields_ = ( + ("response_type", c_uint8), + ("error_code", c_uint8), + ("sequence", c_uint16), + ("resource_id", c_uint32), + ("minor_code", c_uint16), + ("major_code", c_uint8), + ("pad0", c_uint8), + ("pad", c_uint32 * 5), + ("full_sequence", c_uint32), + ) + + +# We special-case InternAtom for convenience. +class InternAtomReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + # This is actually an Atom, not a raw XID, but we handle the type conversion in intern_atom. + ("atom", XID), + ) + + +#### Request / response handling +# +# The following recaps a lot of what's in the xcb-requests(3) man page, with a few notes about what we're doing in +# this library. +# +# In XCB, when you send a request to the server, the function returns immediately. You don't get back the server's +# reply; you get back a "cookie". (This just holds the sequence number of the request.) Later, you can use that +# cookie to get the reply or error back. +# +# This lets you fire off requests in rapid succession, and then afterwards check the results. It also lets you do +# other work (like process a screenshot) while a request is in flight (like getting the next screenshot). This is +# asynchronous processing, and is great for performance. +# +# In this program, we currently don't try to do anything asynchronously, although the design doesn't preclude it. +# (You'd add a synchronous=False flag to the entrypoint wrappers below, and not call .check / .reply, but rather just +# return the cookie.) +# +# XCB has two types of requests. Void requests don't return anything from the server. These are things like "create +# a window". The typed requests do request information from the server. These are things like "get a window's size". +# +# Void requests all return the same type of cookie. The only thing you can do with the cookie is check to see if you +# got an error. +# +# Typed requests return a call-specific cookie with the same structure. They are call-specific so they can be +# type-checked. (This is the case in both XCB C and in this library.) +# +# XCB has a concept of "checked" or "unchecked" request functions. By default, void requests are unchecked. For an +# unchecked function, XCB doesn't do anything to let you know that the request completed successfully. If there's an +# error, then you need to handle it in your main loop, as a regular event. We always use the checked versions +# instead, so that we can raise an exception at the right place in the code. +# +# Similarly, typed requests default to checked, but have unchecked versions. That's just to align their error +# handling with the unchecked void functions; you always need to do something with the cookie so you can get the +# response. +# +# As mentioned, we always use the checked requests; that's unlikely to change, since error-checking with unchecked +# requests requires control of the event loop. +# +# Below are wrappers that set up the request / response functions in ctypes, and define the cookie types to do error +# handling. + + +class XError(ScreenShotError): + """Base exception class for anything related to X11. + + This is not prefixed with Xcb to prevent confusion with the XCB + error structures. + """ + + +class XProtoError(XError): + """Exception indicating server-reported errors.""" + + def __init__(self, xcb_conn: Connection, xcb_err: GenericErrorStructure) -> None: + if isinstance(xcb_err, _Pointer): + xcb_err = xcb_err.contents + assert isinstance(xcb_err, GenericErrorStructure) # noqa: S101 + + details = { + "error_code": xcb_err.error_code, + "sequence": xcb_err.sequence, + "resource_id": xcb_err.resource_id, + "minor_code": xcb_err.minor_code, + "major_code": xcb_err.major_code, + "full_sequence": xcb_err.full_sequence, + } + + # xcb-errors is a library to get descriptive error strings, instead of reporting the raw codes. This is not + # installed by default on most systems, but is quite helpful for developers. We use it if it exists, but + # don't force the matter. We can't delay this lookup until we format the error message, since the XCB + # connection may be gone by then. + if LIB.errors: + # We don't try to reuse the error context, since it's per-connection, and probably will only be used once. + ctx = POINTER(XcbErrorsContext)() + ctx_new_setup = LIB.errors.xcb_errors_context_new(xcb_conn, ctx) + if ctx_new_setup == 0: + try: + # Some of these may return NULL, but some are guaranteed. + ext_name = POINTER(c_char_p)() + error_name = LIB.errors.xcb_errors_get_name_for_error(ctx, xcb_err.error_code, ext_name) + details["error"] = error_name.decode("ascii", errors="replace") + if ext_name: + ext_name_str = ext_name.contents.value + # I'm pretty sure it'll always be populated if ext_name is set, but... + if ext_name_str is not None: + details["extension"] = ext_name_str.decode("ascii", errors="replace") + major_name = LIB.errors.xcb_errors_get_name_for_major_code(ctx, xcb_err.major_code) + details["major_name"] = major_name.decode("ascii", errors="replace") + minor_name = LIB.errors.xcb_errors_get_name_for_minor_code( + ctx, xcb_err.major_code, xcb_err.minor_code + ) + if minor_name: + details["minor_name"] = minor_name.decode("ascii", errors="replace") + finally: + LIB.errors.xcb_errors_context_free(ctx) + + super().__init__("X11 Protocol Error", details=details) + + def __str__(self) -> str: + msg = super().__str__() + details = self.details + error_desc = f"{details['error_code']} ({details['error']})" if "error" in details else details["error_code"] + major_desc = ( + f"{details['major_code']} ({details['major_name']})" if "major_name" in details else details["major_code"] + ) + minor_desc = ( + f"{details['minor_code']} ({details['minor_name']})" if "minor_name" in details else details["minor_code"] + ) + ext_desc = f"\n Extension: {details['extension']}" if "extension" in details else "" + msg += ( + f"\nX Error of failed request: {error_desc}" + f"\n Major opcode of failed request: {major_desc}{ext_desc}" + + (f"\n Minor opcode of failed request: {minor_desc}" if details["minor_code"] != 0 else "") + + f"\n Resource id in failed request: {details['resource_id']}" + f"\n Serial number of failed request: {details['full_sequence']}" + ) + return msg + + +class CookieBase(Structure): + """Generic XCB cookie. + + XCB does not export this as a base type. However, all XCB cookies + have the same structure, so this encompasses the common structure + in Python. + """ + + # It's possible to add a finalizer that will raise an exception if a cookie is garbage collected without being + # disposed of (through discard, check, or reply). If we ever start using asynchronous requests, then that would + # be good to add. But for now, we can trust the wrapper functions to manage the cookies correctly, without the + # extra overhead of these finalizers. + + _fields_ = (("sequence", c_uint),) + + def discard(self, xcb_conn: Connection) -> None: + """Free memory associated with this request, and ignore errors.""" + LIB.xcb.xcb_discard_reply(xcb_conn, self.sequence) + + +class VoidCookie(CookieBase): + """XCB cookie for requests with no responses. + + This corresponds to xcb_void_cookie_t. + """ + + def check(self, xcb_conn: Connection) -> None: + """Verify that the function completed successfully. + + This will raise an exception if there is an error. + """ + err_p = LIB.xcb.xcb_request_check(xcb_conn, self) + if not err_p: + return + err = copy(err_p.contents) + LIB.c.free(err_p) + raise XProtoError(xcb_conn, err) + + +class ReplyCookieBase(CookieBase): + _xcb_reply_func = None + + def reply(self, xcb_conn: Connection) -> Structure: + """Wait for and return the server's response. + + The response will be freed (with libc's free) when it, and its + descendents, are no longer referenced. + + If the server indicates an error, an exception is raised + instead. + """ + err_p = POINTER(GenericErrorStructure)() + assert self._xcb_reply_func is not None # noqa: S101 + reply_p = self._xcb_reply_func(xcb_conn, self, err_p) + if err_p: + # I think this is always NULL, but we can free it. + if reply_p: + LIB.c.free(reply_p) + # Copying the error structure is cheap, and makes memory management easier. + err_copy = copy(err_p.contents) + LIB.c.free(err_p) + raise XProtoError(xcb_conn, err_copy) + assert reply_p # noqa: S101 + + # It's not known, at this point, how long the reply structure actually is: there may be trailing data that + # needs to be processed and then freed. We have to set a finalizer on the reply, so it can be freed when + # Python is done with it. The whole dependency tree, though, leads back to this object and its finalizer. + # Importantly, reply_void_p does not carry a reference (direct or indirect) to reply_p; that would prevent + # it from ever being freed. + reply_void_p = c_void_p(addressof(reply_p.contents)) + finalizer = finalize(reply_p, LIB.c.free, reply_void_p) + finalizer.atexit = False + return reply_p.contents + + +def initialize_xcb_typed_func(lib: CDLL, name: str, request_argtypes: list, reply_struct: type) -> None: + """Set up ctypes for a response-returning XCB function. + + This is only applicable to checked (the default) variants of + functions that have a response type. + + This arranges for the ctypes function to take the given argtypes. + The ctypes function will then return an XcbTypedCookie (rather, + a function-specific subclass of it). That can be used to call the + XCB xcb_blahblah_reply function to check for errors and return the + server's response. + """ + + base_name = name + title_name = base_name.title().replace("_", "") + request_func = getattr(lib, name) + reply_func = getattr(lib, f"{name}_reply") + # The cookie type isn't used outside this function, so we can just declare it here implicitly. + cookie_type = type(f"{title_name}Cookie", (ReplyCookieBase,), {"_xcb_reply_func": reply_func}) + request_func.argtypes = request_argtypes + request_func.restype = cookie_type + reply_func.argtypes = [POINTER(Connection), cookie_type, POINTER(POINTER(GenericErrorStructure))] + reply_func.restype = POINTER(reply_struct) + + +### XCB types + + +class XcbExtension(Structure): + _fields_ = (("name", c_char_p), ("global_id", c_int)) + + +class XcbErrorsContext(Structure): + """A context for using libxcb-errors. + + Create a context with xcb_errors_context_new() and destroy it with + xcb_errors_context_free(). Except for xcb_errors_context_free(), + all functions in libxcb-errors are thread-safe and can be called + from multiple threads at the same time, even on the same context. + """ + + +#### Types for special-cased functions + + +class QueryExtensionReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("present", c_uint8), + ("major_opcode", c_uint8), + ("first_event", c_uint8), + ("first_error", c_uint8), + ) + + +#### XCB libraries singleton + + +class LibContainer: + """Container for XCB-related libraries. + + There is one instance exposed as the xcb.LIB global. + + You can access libxcb.so as xcb.LIB.xcb, libc as xcb.LIB.c, etc. + These are not set up until initialize() is called. It is safe to + call initialize() multiple times. + + Library accesses through this container return the ctypes CDLL + object. There are no smart wrappers (although the return types are + the cookie classes defined above). In other words, if you're + accessing xcb.LIB.xcb.xcb_foo, then you need to handle the .reply() + calls and such yourself. If you're accessing the wrapper functions + in the xcb module xcb.foo, then it will take care of that for you. + """ + + _EXPOSED_NAMES = frozenset( + {"c", "xcb", "randr", "randr_id", "render", "render_id", "xfixes", "xfixes_id", "errors"} + ) + + def __init__(self) -> None: + self._lock = RLock() + self._initializing = False + self.initialized = False + + def reset(self) -> None: + with self._lock: + if self._initializing: + msg = "Cannot reset during initialization" + raise RuntimeError(msg) + self.initialized = False + for name in self._EXPOSED_NAMES: + with suppress(AttributeError): + delattr(self, name) + + def initialize(self, callbacks: Iterable[Callable[[], None]] = frozenset()) -> None: # noqa: PLR0915 + # We'll need a couple of generated types, but we have to load them late, since xcbgen requires this library. + from mss.linux.xcbgen import Setup # noqa: PLC0415 + + with self._lock: + if self.initialized: + # Something else initialized this object while we were waiting for the lock. + return + + if self._initializing: + msg = "Cannot load during initialization" + raise RuntimeError(msg) + + try: + self._initializing = True + + # We don't use the cached versions that ctypes.cdll exposes as attributes, since other libraries may be + # doing their own things with these. + + # We use the libc that the current process has loaded, to make sure we get the right version of free(). + # ctypes doesn't document that None is valid as the argument to LoadLibrary, but it does the same thing + # as a NULL argument to dlopen: it returns the current process and its loaded libraries. This includes + # libc. + self.c = cdll.LoadLibrary(None) # type: ignore[arg-type] + self.c.free.argtypes = [c_void_p] + self.c.free.restype = None + + libxcb_so = ctypes.util.find_library("xcb") + if libxcb_so is None: + msg = "Library libxcb.so not found" + raise ScreenShotError(msg) + self.xcb = cdll.LoadLibrary(libxcb_so) + + self.xcb.xcb_request_check.argtypes = [POINTER(Connection), VoidCookie] + self.xcb.xcb_request_check.restype = POINTER(GenericErrorStructure) + self.xcb.xcb_discard_reply.argtypes = [POINTER(Connection), c_uint] + self.xcb.xcb_discard_reply.restype = None + self.xcb.xcb_get_extension_data.argtypes = [POINTER(Connection), POINTER(XcbExtension)] + self.xcb.xcb_get_extension_data.restype = POINTER(QueryExtensionReply) + self.xcb.xcb_prefetch_extension_data.argtypes = [POINTER(Connection), POINTER(XcbExtension)] + self.xcb.xcb_prefetch_extension_data.restype = None + self.xcb.xcb_generate_id.argtypes = [POINTER(Connection)] + self.xcb.xcb_generate_id.restype = XID + self.xcb.xcb_get_setup.argtypes = [POINTER(Connection)] + self.xcb.xcb_get_setup.restype = POINTER(Setup) + self.xcb.xcb_connection_has_error.argtypes = [POINTER(Connection)] + self.xcb.xcb_connection_has_error.restype = c_int + self.xcb.xcb_connect.argtypes = [c_char_p, POINTER(c_int)] + self.xcb.xcb_connect.restype = POINTER(Connection) + self.xcb.xcb_disconnect.argtypes = [POINTER(Connection)] + self.xcb.xcb_disconnect.restype = None + + # We special-case InternAtom for convenience. + initialize_xcb_typed_func( + LIB.xcb, + "xcb_intern_atom", + [POINTER(Connection), c_uint8, c_uint16, POINTER(c_char)], + InternAtomReply, + ) + + libxcb_randr_so = ctypes.util.find_library("xcb-randr") + if libxcb_randr_so is None: + msg = "Library libxcb-randr.so not found" + raise ScreenShotError(msg) + self.randr = cdll.LoadLibrary(libxcb_randr_so) + self.randr_id = XcbExtension.in_dll(self.randr, "xcb_randr_id") + + libxcb_render_so = ctypes.util.find_library("xcb-render") + if libxcb_render_so is None: + msg = "Library libxcb-render.so not found" + raise ScreenShotError(msg) + self.render = cdll.LoadLibrary(libxcb_render_so) + self.render_id = XcbExtension.in_dll(self.render, "xcb_render_id") + + libxcb_shm_so = ctypes.util.find_library("xcb-shm") + if libxcb_shm_so is None: + msg = "Library libxcb-shm.so not found" + raise ScreenShotError(msg) + self.shm = cdll.LoadLibrary(libxcb_shm_so) + self.shm_id = XcbExtension.in_dll(self.shm, "xcb_shm_id") + + libxcb_xfixes_so = ctypes.util.find_library("xcb-xfixes") + if libxcb_xfixes_so is None: + msg = "Library libxcb-xfixes.so not found" + raise ScreenShotError(msg) + self.xfixes = cdll.LoadLibrary(libxcb_xfixes_so) + self.xfixes_id = XcbExtension.in_dll(self.xfixes, "xcb_xfixes_id") + + # xcb_errors is an optional library, mostly only useful to developers. We use the qualified .so name, + # since it's subject to change incompatibly. + try: + self.errors: CDLL | None = cdll.LoadLibrary("libxcb-errors.so.0") + except Exception: # noqa: BLE001 + self.errors = None + else: + self.errors.xcb_errors_context_new.argtypes = [ + POINTER(Connection), + POINTER(POINTER(XcbErrorsContext)), + ] + self.errors.xcb_errors_context_new.restype = c_int + self.errors.xcb_errors_context_free.argtypes = [POINTER(XcbErrorsContext)] + self.errors.xcb_errors_context_free.restype = None + self.errors.xcb_errors_get_name_for_major_code.argtypes = [POINTER(XcbErrorsContext), c_uint8] + self.errors.xcb_errors_get_name_for_major_code.restype = c_char_p + self.errors.xcb_errors_get_name_for_minor_code.argtypes = [ + POINTER(XcbErrorsContext), + c_uint8, + c_uint16, + ] + self.errors.xcb_errors_get_name_for_minor_code.restype = c_char_p + self.errors.xcb_errors_get_name_for_error.argtypes = [ + POINTER(XcbErrorsContext), + c_uint8, + POINTER(c_char_p), + ] + self.errors.xcb_errors_get_name_for_error.restype = c_char_p + + for x in callbacks: + x() + + finally: + self._initializing = False + + self.initialized = True + + +LIB = LibContainer() + + +#### Trailing data accessors +# +# In X11, many replies have the header (the *Reply structures defined above), plus some variable-length data after it. +# For instance, XcbScreen includes a list of XcbDepth structures. +# +# These mostly follow two patterns. +# +# For objects with a constant size, we get a pointer and length (count), cast to an array, and return the array +# contents. (This doesn't involve copying any data.) +# +# For objects with a variable size, we use the XCB-provided iterator protocol to iterate over them, and return a +# Python list. (This also doesn't copy any data, but does construct a list.) To continue the example of how +# XcbScreen includes a list of XcbDepth structures: a full XcbDepth is variable-length because it has a variable +# number of visuals attached to it. +# +# These lists with variable element sizes follow a standard pattern: +# +# * There is an iterator class (such as XcbScreenIterator), based on the type you're iterating over. This defines a +# data pointer to point to the current object, and a rem counter indicating the remaining number of objects. +# * There is a function to advance the iterator (such as xcb_screen_next), based on the type of iterator being +# advanced. +# * There is an initializer function (such as xcb_setup_roots_iterator) that takes the container (XcbSetup), and +# returns an iterator (XcbScreenIterator) pointing to the first object in the list. (This iterator is returned by +# value, so Python can free it normally.) +# +# The returned structures are actually part of the allocation of the parent pointer: the POINTER(XcbScreen) objects +# point to objects that were allocated along with the XcbSetup that we got them from. That means that it is very +# important that the XcbSetup not be freed until the pointers that point into it are freed. + + +### Iteration utility primitives + + +def list_from_xcb(iterator_factory: Callable, next_func: Callable, parent: Structure | _Pointer) -> list: + iterator = iterator_factory(parent) + items: list = [] + while iterator.rem != 0: + current = iterator.data.contents + # Keep the parent reply alive until consumers drop this entry. + depends_on(current, parent) + items.append(current) + next_func(iterator) + return items + + +def array_from_xcb(pointer_func: Callable, length_func: Callable, parent: Structure | _Pointer) -> Array: + pointer = pointer_func(parent) + length = length_func(parent) + if length and not pointer: + msg = "XCB returned a NULL pointer for non-zero data length" + raise ScreenShotError(msg) + array_ptr = cast(pointer, POINTER(pointer._type_ * length)) + array = array_ptr.contents + depends_on(array, parent) + return array diff --git a/src/mss/linux/xgetimage.py b/src/mss/linux/xgetimage.py new file mode 100644 index 00000000..ad28c056 --- /dev/null +++ b/src/mss/linux/xgetimage.py @@ -0,0 +1,28 @@ +"""XCB-based backend using the XGetImage request. + +This backend issues XCB ``GetImage`` requests and supports the RandR and +XFixes extensions when available for monitor enumeration and cursor capture. + +This backend will work on any X connection, but is slower than the xshmgetimage +backend. + +.. versionadded:: 10.2.0 +""" + +from mss.linux.base import MSSImplXCBBase +from mss.models import Monitor + +__all__ = () + + +class MSSImplXGetImage(MSSImplXCBBase): + """XCB backend using XGetImage requests on GNU/Linux. + + .. seealso:: + :py:class:`mss.linux.base.MSSXCBBase` + Lists constructor parameters. + """ + + def grab(self, monitor: Monitor) -> bytearray: + """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" + return super()._grab_xgetimage(monitor) diff --git a/src/mss/linux/xlib.py b/src/mss/linux/xlib.py new file mode 100644 index 00000000..bd95049c --- /dev/null +++ b/src/mss/linux/xlib.py @@ -0,0 +1,642 @@ +"""Legacy Xlib-based GNU/Linux backend. + +This backend talks to X11 via Xlib and the Xrandr extension, and is retained +as a fallback when XCB backends are unavailable. Cursor capture uses XFixes +when available. + +.. versionadded:: 10.2.0 Prior to this version, this was available as + ``mss.linux.MSS``. +""" + +from __future__ import annotations + +import locale +import os +from contextlib import suppress +from ctypes import ( + CFUNCTYPE, + POINTER, + Structure, + _Pointer, + byref, + c_char_p, + c_int, + c_short, + c_ubyte, + c_uint, + c_ulong, + c_ushort, + c_void_p, + cast, + cdll, + create_string_buffer, +) +from ctypes.util import find_library +from threading import Lock, current_thread, local +from typing import TYPE_CHECKING, Any + +from mss.base import MSSImplementation +from mss.exception import ScreenShotError +from mss.screenshot import ScreenShot + +if TYPE_CHECKING: + from mss.models import CFunctions, Monitor, Monitors + +__all__ = () + + +# Global lock protecting access to Xlib calls. +# A per-object lock must not be acquired while this is held. It is safe to acquire this global lock while a +# per-object lock is currently held. +_lock = Lock() + +X_FIRST_EXTENSION_OPCODE = 128 +PLAINMASK = 0x00FFFFFF +ZPIXMAP = 2 +BITS_PER_PIXELS_32 = 32 +SUPPORTED_BITS_PER_PIXELS = { + BITS_PER_PIXELS_32, +} + + +class XID(c_ulong): + """X11 generic resource ID + https://tronche.com/gui/x/xlib/introduction/generic.html + https://gitlab.freedesktop.org/xorg/proto/xorgproto/-/blob/master/include/X11/X.h#L66 + """ + + +class XStatus(c_int): + """Xlib common return code type + This is Status in Xlib, but XStatus here to prevent ambiguity. + Zero is an error, non-zero is success. + https://tronche.com/gui/x/xlib/introduction/errors.html + https://gitlab.freedesktop.org/xorg/lib/libx11/-/blob/master/include/X11/Xlib.h#L79 + """ + + +class XBool(c_int): + """Xlib boolean type + This is Bool in Xlib, but XBool here to prevent ambiguity. + 0 is False, 1 is True. + https://tronche.com/gui/x/xlib/introduction/generic.html + https://gitlab.freedesktop.org/xorg/lib/libx11/-/blob/master/include/X11/Xlib.h#L78 + """ + + +class Display(Structure): + """Structure that serves as the connection to the X server + and that contains all the information about that X server. + The contents of this structure are implementation dependent. + A Display should be treated as opaque by application code. + https://tronche.com/gui/x/xlib/display/display-macros.html + https://gitlab.freedesktop.org/xorg/lib/libx11/-/blob/master/include/X11/Xlib.h#L477 + https://github.com/garrybodsworth/pyxlib-ctypes/blob/master/pyxlib/xlib.py#L831. + """ + + # Opaque data + + +class Visual(Structure): + """Visual structure; contains information about colormapping possible. + https://tronche.com/gui/x/xlib/window/visual-types.html + https://gitlab.freedesktop.org/xorg/lib/libx11/-/blob/master/include/X11/Xlib.hheads#L220 + https://github.com/garrybodsworth/pyxlib-ctypes/blob/master/pyxlib/xlib.py#302. + """ + + # Opaque data (per Tronche) + + +class Screen(Structure): + """Information about the screen. + The contents of this structure are implementation dependent. A + Screen should be treated as opaque by application code. + https://tronche.com/gui/x/xlib/display/screen-information.html + https://gitlab.freedesktop.org/xorg/lib/libx11/-/blob/master/include/X11/Xlib.h#L253 + """ + + # Opaque data + + +class XErrorEvent(Structure): + """XErrorEvent to debug eventual errors. + https://tronche.com/gui/x/xlib/event-handling/protocol-errors/default-handlers.html. + https://gitlab.freedesktop.org/xorg/lib/libx11/-/blob/master/include/X11/Xlib.h#L920 + """ + + _fields_ = ( + ("type", c_int), + ("display", POINTER(Display)), # Display the event was read from + ("resourceid", XID), # resource ID + ("serial", c_ulong), # serial number of failed request + ("error_code", c_ubyte), # error code of failed request + ("request_code", c_ubyte), # major op-code of failed request + ("minor_code", c_ubyte), # minor op-code of failed request + ) + + +class XFixesCursorImage(Structure): + """Cursor structure. + /usr/include/X11/extensions/Xfixes.h + https://github.com/freedesktop/xorg-libXfixes/blob/libXfixes-6.0.0/include/X11/extensions/Xfixes.h#L96. + """ + + _fields_ = ( + ("x", c_short), + ("y", c_short), + ("width", c_ushort), + ("height", c_ushort), + ("xhot", c_ushort), + ("yhot", c_ushort), + ("cursor_serial", c_ulong), + ("pixels", POINTER(c_ulong)), + ("atom", c_ulong), + ("name", c_char_p), + ) + + +class XImage(Structure): + """Description of an image as it exists in the client's memory. + https://tronche.com/gui/x/xlib/graphics/images.html + https://gitlab.freedesktop.org/xorg/lib/libx11/-/blob/master/include/X11/Xlib.h#L353 + """ + + _fields_ = ( + ("width", c_int), # size of image + ("height", c_int), # size of image + ("xoffset", c_int), # number of pixels offset in X direction + ("format", c_int), # XYBitmap, XYPixmap, ZPixmap + ("data", c_void_p), # pointer to image data + ("byte_order", c_int), # data byte order, LSBFirst, MSBFirst + ("bitmap_unit", c_int), # quant. of scanline 8, 16, 32 + ("bitmap_bit_order", c_int), # LSBFirst, MSBFirst + ("bitmap_pad", c_int), # 8, 16, 32 either XY or ZPixmap + ("depth", c_int), # depth of image + ("bytes_per_line", c_int), # accelerator to next line + ("bits_per_pixel", c_int), # bits per pixel (ZPixmap) + ("red_mask", c_ulong), # bits in z arrangement + ("green_mask", c_ulong), # bits in z arrangement + ("blue_mask", c_ulong), # bits in z arrangement + ) + # Other opaque fields follow for Xlib's internal use. + + +class XRRCrtcInfo(Structure): + """Structure that contains CRTC information. + https://gitlab.freedesktop.org/xorg/lib/libxrandr/-/blob/master/include/X11/extensions/Xrandr.h#L360. + """ + + _fields_ = ( + ("timestamp", c_ulong), + ("x", c_int), + ("y", c_int), + ("width", c_uint), + ("height", c_uint), + ("mode", XID), + ("rotation", c_ushort), + ("noutput", c_int), + ("outputs", POINTER(XID)), + ("rotations", c_ushort), + ("npossible", c_int), + ("possible", POINTER(XID)), + ) + + +class XRRModeInfo(Structure): + """https://gitlab.freedesktop.org/xorg/lib/libxrandr/-/blob/master/include/X11/extensions/Xrandr.h#L248.""" + + # The fields aren't needed + + +class XRRScreenResources(Structure): + """Structure that contains arrays of XIDs that point to the + available outputs and associated CRTCs. + https://gitlab.freedesktop.org/xorg/lib/libxrandr/-/blob/master/include/X11/extensions/Xrandr.h#L265. + """ + + _fields_ = ( + ("timestamp", c_ulong), + ("configTimestamp", c_ulong), + ("ncrtc", c_int), + ("crtcs", POINTER(XID)), + ("noutput", c_int), + ("outputs", POINTER(XID)), + ("nmode", c_int), + ("modes", POINTER(XRRModeInfo)), + ) + + +class XWindowAttributes(Structure): + """Attributes for the specified window. + https://tronche.com/gui/x/xlib/window-information/XGetWindowAttributes.html + https://gitlab.freedesktop.org/xorg/lib/libx11/-/blob/master/include/X11/Xlib.h#L304 + """ + + _fields_ = ( + ("x", c_int), # location of window + ("y", c_int), # location of window + ("width", c_int), # width of window + ("height", c_int), # height of window + ("border_width", c_int), # border width of window + ("depth", c_int), # depth of window + ("visual", POINTER(Visual)), # the associated visual structure + ("root", XID), # root of screen containing window + ("class", c_int), # InputOutput, InputOnly + ("bit_gravity", c_int), # one of bit gravity values + ("win_gravity", c_int), # one of the window gravity values + ("backing_store", c_int), # NotUseful, WhenMapped, Always + ("backing_planes", c_ulong), # planes to be preserved if possible + ("backing_pixel", c_ulong), # value to be used when restoring planes + ("save_under", XBool), # boolean, should bits under be saved? + ("colormap", XID), # color map to be associated with window + ("mapinstalled", XBool), # boolean, is color map currently installed + ("map_state", c_uint), # IsUnmapped, IsUnviewable, IsViewable + ("all_event_masks", c_ulong), # set of events all people have interest in + ("your_event_mask", c_ulong), # my event mask + ("do_not_propagate_mask", c_ulong), # set of events that should not propagate + ("override_redirect", XBool), # boolean value for override-redirect + ("screen", POINTER(Screen)), # back pointer to correct screen + ) + + +_ERROR = {} +_X11 = find_library("X11") +_XFIXES = find_library("Xfixes") +_XRANDR = find_library("Xrandr") + + +class XError(ScreenShotError): + def __str__(self) -> str: + msg = super().__str__() + # The details only get populated if the X11 error handler is invoked, but not if a function simply returns + # a failure status. + if self.details: + # We use something similar to the default Xlib error handler's format, since that's quite well-understood. + # The original code is in + # https://gitlab.freedesktop.org/xorg/lib/libx11/-/blob/master/src/XlibInt.c?ref_type=heads#L1313 + # but we don't try to implement most of it. + msg += ( + f"\nX Error of failed request: {self.details['error']}" + f"\n Major opcode of failed request: {self.details['request_code']} ({self.details['request']})" + ) + if self.details["request_code"] >= X_FIRST_EXTENSION_OPCODE: + msg += f"\n Minor opcode of failed request: {self.details['minor_code']}" + msg += ( + f"\n Resource id in failed request: {self.details['resourceid'].value}" + f"\n Serial number of failed request: {self.details['serial']}" + ) + return msg + + +@CFUNCTYPE(c_int, POINTER(Display), POINTER(XErrorEvent)) +def _error_handler(display: Display, event: XErrorEvent) -> int: + """Specifies the program's supplied error handler.""" + # Get the specific error message + xlib = cdll.LoadLibrary(_X11) # type: ignore[arg-type] + get_error = xlib.XGetErrorText + get_error.argtypes = [POINTER(Display), c_int, c_char_p, c_int] + get_error.restype = c_void_p + get_error_database = xlib.XGetErrorDatabaseText + get_error_database.argtypes = [POINTER(Display), c_char_p, c_char_p, c_char_p, c_char_p, c_int] + get_error_database.restype = c_int + + evt = event.contents + error = create_string_buffer(1024) + get_error(display, evt.error_code, error, len(error)) + request = create_string_buffer(1024) + get_error_database(display, b"XRequest", b"%i" % evt.request_code, b"Extension-specific", request, len(request)) + # We don't try to get the string forms of the extension name or minor code currently. Those are important + # fields for debugging, but getting the strings is difficult. The call stack of the exception gives pretty + # useful similar information, though; most of the requests we use are synchronous, so the failing request is + # usually the function being called. + + encoding = ( + locale.getencoding() if hasattr(locale, "getencoding") else locale.getpreferredencoding(do_setlocale=False) + ) + _ERROR[current_thread()] = { + "error": error.value.decode(encoding, errors="replace"), + "error_code": evt.error_code, + "minor_code": evt.minor_code, + "request": request.value.decode(encoding, errors="replace"), + "request_code": evt.request_code, + "serial": evt.serial, + "resourceid": evt.resourceid, + "type": evt.type, + } + + return 0 + + +def _validate_x11( + retval: _Pointer | None | XBool | XStatus | XID | int, func: Any, args: tuple[Any, Any], / +) -> tuple[Any, Any]: + thread = current_thread() + + if retval is None: + # A void return is always ok. + is_ok = True + elif isinstance(retval, (_Pointer, XBool, XStatus, XID)): + # A pointer should be non-NULL. A boolean should be true. An Xlib Status should be non-zero. + # An XID should not be None, which is a reserved ID used for certain APIs. + is_ok = bool(retval) + elif isinstance(retval, int): + # There are currently two functions we call that return ints. XDestroyImage returns 1 always, and + # XCloseDisplay returns 0 always. Neither can fail. Other Xlib functions might return ints with other + # interpretations. If we didn't get an X error from the server, then we'll assume that they worked. + is_ok = True + else: + msg = f"Internal error: cannot check return type {type(retval)}" + raise AssertionError(msg) + + # Regardless of the return value, raise an error if the thread got an Xlib error (possibly from an earlier call). + if is_ok and thread not in _ERROR: + return args + + details = _ERROR.pop(thread, {}) + msg = f"{func.__name__}() failed" + raise XError(msg, details=details) + + +# C functions that will be initialised later. +# See https://tronche.com/gui/x/xlib/function-index.html for details. +# +# Available attr: xfixes, xlib, xrandr. +# +# Note: keep it sorted by cfunction. +CFUNCTIONS: CFunctions = { + # Syntax: cfunction: (attr, argtypes, restype) + "XCloseDisplay": ("xlib", [POINTER(Display)], c_int), + "XDefaultRootWindow": ("xlib", [POINTER(Display)], XID), + "XDestroyImage": ("xlib", [POINTER(XImage)], c_int), + "XFixesGetCursorImage": ("xfixes", [POINTER(Display)], POINTER(XFixesCursorImage)), + "XGetImage": ( + "xlib", + [POINTER(Display), XID, c_int, c_int, c_uint, c_uint, c_ulong, c_int], + POINTER(XImage), + ), + "XGetWindowAttributes": ("xlib", [POINTER(Display), XID, POINTER(XWindowAttributes)], XStatus), + "XOpenDisplay": ("xlib", [c_char_p], POINTER(Display)), + "XQueryExtension": ("xlib", [POINTER(Display), c_char_p, POINTER(c_int), POINTER(c_int), POINTER(c_int)], XBool), + "XRRQueryVersion": ("xrandr", [POINTER(Display), POINTER(c_int), POINTER(c_int)], XStatus), + "XRRFreeCrtcInfo": ("xrandr", [POINTER(XRRCrtcInfo)], None), + "XRRFreeScreenResources": ("xrandr", [POINTER(XRRScreenResources)], None), + "XRRGetCrtcInfo": ("xrandr", [POINTER(Display), POINTER(XRRScreenResources), XID], POINTER(XRRCrtcInfo)), + "XRRGetScreenResources": ("xrandr", [POINTER(Display), XID], POINTER(XRRScreenResources)), + "XRRGetScreenResourcesCurrent": ("xrandr", [POINTER(Display), XID], POINTER(XRRScreenResources)), + "XSetErrorHandler": ("xlib", [c_void_p], c_void_p), +} + + +class MSSImplXlib(MSSImplementation): + """Multiple ScreenShots implementation for GNU/Linux. + It uses intensively the Xlib and its Xrandr extension. + + :param display: Optional keyword argument. + Specifies an X11 display string to connect to. The default is + taken from the environment variable :envvar:`DISPLAY`. + :type display: str | bytes | None + + .. danger:: + The Xlib backend does not have the same multithreading + guarantees as the rest of MSS. Specifically, the object may + only be used on the thread in which it was created. + Additionally, while rare, using multiple MSS objects in + different threads simultaneously may still cause problems. + + .. seealso:: + :py:class:`mss.MSS` + Lists other parameters. + """ + + __slots__ = {"_handles", "xfixes", "xlib", "xrandr"} + + def __init__(self, /, display: bytes | str = b"", **kwargs: Any) -> None: + requested_with_cursor = bool(kwargs.pop("with_cursor", False)) + effective_with_cursor = requested_with_cursor and bool(_XFIXES) + super().__init__(with_cursor=effective_with_cursor, **kwargs) + + # Available thread-specific variables + self._handles = local() + self._handles.display = None + self._handles.drawable = None + self._handles.original_error_handler = None + self._handles.root = None + + if not display: + try: + display = os.environ["DISPLAY"].encode("utf-8") + except KeyError: + msg = "$DISPLAY not set." + raise ScreenShotError(msg) from None + + if not isinstance(display, bytes): + display = display.encode("utf-8") + + if b":" not in display: + msg = f"Bad display value: {display!r}." + raise ScreenShotError(msg) + + if not _X11: + msg = "No X11 library found." + raise ScreenShotError(msg) + #: :meta private: + self.xlib = cdll.LoadLibrary(_X11) + + if not _XRANDR: + msg = "No Xrandr extension found." + raise ScreenShotError(msg) + #: :meta private: + self.xrandr = cdll.LoadLibrary(_XRANDR) + + if effective_with_cursor: + # MyPy doesn't quite realize that won't be called if _XFIXES is None. + #: :meta private: + self.xfixes = cdll.LoadLibrary(_XFIXES) if _XFIXES is not None else None + + self._set_cfunctions() + + with _lock: + # Install the error handler to prevent interpreter crashes: any error will raise a ScreenShotError + # exception + self._handles.original_error_handler = self.xlib.XSetErrorHandler(_error_handler) + + self._handles.display = self.xlib.XOpenDisplay(display) + if not self._handles.display: + msg = f"Unable to open display: {display!r}." + raise ScreenShotError(msg) + + self._handles.drawable = self._handles.root = self.xlib.XDefaultRootWindow(self._handles.display) + + if not self._is_extension_enabled("RANDR"): + msg = "Xrandr not enabled." + raise ScreenShotError(msg) + + def close(self) -> None: + # Clean-up + if self._handles.display: + with _lock: + self.xlib.XCloseDisplay(self._handles.display) + self._handles.display = None + self._handles.drawable = None + self._handles.root = None + + # Remove our error handler + if self._handles.original_error_handler: + # It's required when exiting MSS to prevent letting `_error_handler()` as default handler. + # Doing so would crash when using Tk/Tkinter, see issue #220. + # Interesting technical stuff can be found here: + # https://core.tcl-lang.org/tk/file?name=generic/tkError.c&ci=a527ef995862cb50 + # https://github.com/tcltk/tk/blob/b9cdafd83fe77499ff47fa373ce037aff3ae286a/generic/tkError.c + with _lock: + self.xlib.XSetErrorHandler(self._handles.original_error_handler) + self._handles.original_error_handler = None + + # Also empty the error dict + _ERROR.clear() + + def _is_extension_enabled(self, name: str, /) -> bool: + """Return True if the given *extension* is enabled on the server.""" + major_opcode_return = c_int() + first_event_return = c_int() + first_error_return = c_int() + + try: + with _lock: + self.xlib.XQueryExtension( + self._handles.display, + name.encode("latin1"), + byref(major_opcode_return), + byref(first_event_return), + byref(first_error_return), + ) + except ScreenShotError: + return False + return True + + def _set_cfunctions(self) -> None: + """Set all ctypes functions and attach them to attributes.""" + cfactory = self._cfactory + attrs = { + "xfixes": getattr(self, "xfixes", None), + "xlib": self.xlib, + "xrandr": self.xrandr, + } + for func, (attr, argtypes, restype) in CFUNCTIONS.items(): + with suppress(AttributeError): + errcheck = None if func == "XSetErrorHandler" else _validate_x11 + cfactory(attrs[attr], func, argtypes, restype, errcheck=errcheck) + + def monitors(self) -> Monitors: + """Get positions of monitors.""" + display = self._handles.display + int_ = int + xrandr = self.xrandr + monitors = [] + + with _lock: + xrandr_major = c_int(0) + xrandr_minor = c_int(0) + xrandr.XRRQueryVersion(display, xrandr_major, xrandr_minor) + + # All monitors + gwa = XWindowAttributes() + self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa)) + monitors.append( + {"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)}, + ) + + # Each monitor + # A simple benchmark calling 10 times those 2 functions: + # XRRGetScreenResources(): 0.1755971429956844 s + # XRRGetScreenResourcesCurrent(): 0.0039125580078689 s + # The second is faster by a factor of 44! So try to use it first. + # It doesn't query the monitors for updated information, but it does require the server to support RANDR + # 1.3. We also make sure the client supports 1.3, by checking for the presence of the function. + if hasattr(xrandr, "XRRGetScreenResourcesCurrent") and (xrandr_major.value, xrandr_minor.value) >= (1, 3): + mon = xrandr.XRRGetScreenResourcesCurrent(display, self._handles.drawable).contents + else: + mon = xrandr.XRRGetScreenResources(display, self._handles.drawable).contents + + crtcs = mon.crtcs + for idx in range(mon.ncrtc): + crtc = xrandr.XRRGetCrtcInfo(display, mon, crtcs[idx]).contents + if crtc.noutput == 0: + xrandr.XRRFreeCrtcInfo(crtc) + continue + + monitors.append( + { + "left": int_(crtc.x), + "top": int_(crtc.y), + "width": int_(crtc.width), + "height": int_(crtc.height), + }, + ) + xrandr.XRRFreeCrtcInfo(crtc) + xrandr.XRRFreeScreenResources(mon) + + return monitors + + def grab(self, monitor: Monitor, /) -> bytearray: + """Retrieve all pixels from a monitor. Pixels have to be RGB.""" + + with _lock: + ximage = self.xlib.XGetImage( + self._handles.display, + self._handles.drawable, + monitor["left"], + monitor["top"], + monitor["width"], + monitor["height"], + PLAINMASK, + ZPIXMAP, + ) + + try: + bits_per_pixel = ximage.contents.bits_per_pixel + 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) + + raw_data = cast( + ximage.contents.data, + POINTER(c_ubyte * monitor["height"] * monitor["width"] * 4), + ) + data = bytearray(raw_data.contents) + finally: + # Free + with _lock: + self.xlib.XDestroyImage(ximage) + + return data + + def cursor(self) -> ScreenShot | None: + """Retrieve all cursor data. Pixels have to be RGB.""" + if self.xfixes is None: + return None + + # Read data of cursor/mouse-pointer + with _lock: + ximage = self.xfixes.XFixesGetCursorImage(self._handles.display) + if not (ximage and ximage.contents): + msg = "Cannot read XFixesGetCursorImage()" + raise ScreenShotError(msg) + + cursor_img: XFixesCursorImage = ximage.contents + region = { + "left": cursor_img.x - cursor_img.xhot, + "top": cursor_img.y - cursor_img.yhot, + "width": cursor_img.width, + "height": cursor_img.height, + } + + raw_data = cast(cursor_img.pixels, POINTER(c_ulong * region["height"] * region["width"])) + raw = bytearray(raw_data.contents) + + data = bytearray(region["height"] * region["width"] * 4) + data[3::4] = raw[3::8] + data[2::4] = raw[2::8] + data[1::4] = raw[1::8] + data[::4] = raw[::8] + + return ScreenShot(data, region) diff --git a/src/mss/linux/xshmgetimage.py b/src/mss/linux/xshmgetimage.py new file mode 100644 index 00000000..b7cc3646 --- /dev/null +++ b/src/mss/linux/xshmgetimage.py @@ -0,0 +1,229 @@ +"""XCB backend using MIT-SHM XShmGetImage with automatic fallback. + +This is the fastest Linux backend available, and will work in most common +cases. However, it will not work over remote X connections, such as over ssh. + +This implementation prefers shared-memory captures for performance and will +fall back to XGetImage when the MIT-SHM extension is unavailable or fails at +runtime. The fallback reason is exposed via ``performance_status`` to aid +debugging. + +.. versionadded:: 10.2.0 +""" + +from __future__ import annotations + +import enum +import os +from mmap import PROT_READ, mmap # type: ignore[attr-defined] +from typing import TYPE_CHECKING, Any + +from mss.exception import ScreenShotError +from mss.linux import xcb +from mss.linux.base import ALL_PLANES, MSSImplXCBBase +from mss.linux.xcbhelpers import LIB, XProtoError + +if TYPE_CHECKING: + from mss.models import Monitor + +__all__ = () + + +class ShmStatus(enum.Enum): + """Availability of the MIT-SHM extension for this backend.""" + + UNKNOWN = enum.auto() # Constructor says SHM *should* work, but we haven't seen a real GetImage succeed yet. + AVAILABLE = enum.auto() # We've successfully used XShmGetImage at least once. + UNAVAILABLE = enum.auto() # We know SHM GetImage is unusable; always use XGetImage. + + +class MSSImplXShmGetImage(MSSImplXCBBase): + """XCB backend using XShmGetImage with an automatic XGetImage fallback. + + .. seealso:: + :py:class:`mss.linux.base.MSSImplXCBBase` + Lists constructor parameters. + """ + + def __init__(self, *, display: str | bytes | None = None, with_cursor: bool = False) -> None: + super().__init__(display=display, with_cursor=with_cursor) + + # These are the objects we need to clean up when we shut down. They are created in _setup_shm. + self._memfd: int | None = None + self._buf: mmap | None = None + self._shmseg: xcb.ShmSeg | None = None + + # Rather than trying to track the shm_status, we may be able to raise an exception in __init__ if XShmGetImage + # isn't available. The factory in linux/__init__.py could then catch that and switch to XGetImage. + # The conditions under which the attach will succeed but the xcb_shm_get_image will fail are extremely + # rare, and I haven't yet found any that also will work with xcb_get_image. + #: Whether we can use the MIT-SHM extensions for this connection. + #: This will not be ``AVAILABLE`` until at least one capture has succeeded. + #: It may be set to ``UNAVAILABLE`` sooner. + self.shm_status: ShmStatus = self._setup_shm() + + def _shm_report_issue(self, msg: str, *args: Any) -> None: + """Debugging hook for troubleshooting MIT-SHM issues. + + This will be called whenever MIT-SHM is disabled. The optional + arguments are not well-defined; exceptions are common. + """ + full_msg = msg + if args: + full_msg += " | " + ", ".join(str(arg) for arg in args) + self.performance_status.append(full_msg) + + def _setup_shm(self) -> ShmStatus: # noqa: PLR0911 + assert self.conn is not None # noqa: S101 + + try: + shm_ext_data = xcb.get_extension_data(self.conn, LIB.shm_id) + if not shm_ext_data.present: + self._shm_report_issue("MIT-SHM extension not present") + return ShmStatus.UNAVAILABLE + + # We use the FD-based version of ShmGetImage, so we require the extension to be at least 1.2. + shm_version_data = xcb.shm_query_version(self.conn) + shm_version = (shm_version_data.major_version, shm_version_data.minor_version) + if shm_version < (1, 2): + self._shm_report_issue("MIT-SHM version too old", shm_version) + return ShmStatus.UNAVAILABLE + + # We allocate something large enough for the root, so we don't have to reallocate each time the window is + # resized. + self._bufsize = self.pref_screen.width_in_pixels * self.pref_screen.height_in_pixels * 4 + + if not hasattr(os, "memfd_create"): + self._shm_report_issue("os.memfd_create not available") + return ShmStatus.UNAVAILABLE + try: + self._memfd = os.memfd_create("mss-shm-buf", flags=os.MFD_CLOEXEC) # type: ignore[attr-defined] + except OSError as e: + return self._shm_unavailable("memfd_create failed", e) + os.ftruncate(self._memfd, self._bufsize) + + try: + self._buf = mmap(self._memfd, self._bufsize, prot=PROT_READ) # type: ignore[call-arg] + except OSError as e: + return self._shm_unavailable("mmap failed", e) + self._shmseg = xcb.ShmSeg(xcb.generate_id(self.conn).value) + try: + # This will normally be what raises an exception if you're on a remote connection. + # XCB will close _memfd, on success or on failure. + try: + xcb.shm_attach_fd(self.conn, self._shmseg, self._memfd, read_only=False) + finally: + self._memfd = None + except xcb.XError as e: + return self._shm_unavailable("Cannot attach MIT-SHM segment", e) + except Exception: + self._shutdown_shm() + raise + + return ShmStatus.UNKNOWN + + def _shm_unavailable(self, msg: str, exc: Exception) -> ShmStatus: + self._shm_report_issue(msg, exc) + self._shutdown_shm() + return ShmStatus.UNAVAILABLE + + def close(self) -> None: + self._shutdown_shm() + super().close() + + def _shutdown_shm(self) -> None: + # It would be nice to also try to tell the server to detach the shmseg, but we might be in an error path + # and don't know if that's possible. It's not like we'll leak a lot of them on the same connection anyway. + # This can be called in the path of partial initialization. + if self._buf is not None: + self._buf.close() + self._buf = None + if self._memfd is not None: + os.close(self._memfd) + self._memfd = None + + def _grab_xshmgetimage(self, monitor: Monitor) -> bytearray: + if self.conn is None: + msg = "Cannot take screenshot while the connection is closed" + raise ScreenShotError(msg) + assert self._buf is not None # noqa: S101 + assert self._shmseg is not None # noqa: S101 + + required_size = monitor["width"] * monitor["height"] * 4 + if required_size > self._bufsize: + # This is temporary. The permanent fix will depend on how + # issue https://github.com/BoboTiG/python-mss/issues/432 is resolved. + msg = ( + "Requested capture size exceeds the allocated buffer. If you have resized the screen, " + "please recreate your MSS object." + ) + raise ScreenShotError(msg) + + img_reply = xcb.shm_get_image( + self.conn, + self.drawable, + monitor["left"], + monitor["top"], + monitor["width"], + monitor["height"], + ALL_PLANES, + xcb.ImageFormat.ZPixmap, + self._shmseg, + 0, + ) + + if img_reply.depth != self.drawable_depth or img_reply.visual != self.drawable_visual_id: + # This should never happen; a window can't change its visual. + msg = ( + "Server returned an image with a depth or visual different than it initially reported: " + f"expected {self.drawable_depth},{hex(self.drawable_visual_id.value)}, " + f"got {img_reply.depth},{hex(img_reply.visual.value)}" + ) + raise ScreenShotError(msg) + + # Snapshot the buffer into new bytearray. + new_size = monitor["width"] * monitor["height"] * 4 + # Slicing the memoryview creates a new memoryview that points to the relevant subregion. Making this and then + # copying it into a fresh bytearray is much faster than slicing the mmap object. Make sure we don't hold an + # open memoryview if an exception happens, since that will prevent us from closing self._buf during the stack + # unwind. + with memoryview(self._buf) as img_mv: + return bytearray(img_mv[:new_size]) + + def grab(self, monitor: Monitor) -> bytearray: + """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" + if self.shm_status == ShmStatus.UNAVAILABLE: + return super()._grab_xgetimage(monitor) + + # The usual path is just the next few lines. + try: + rv = self._grab_xshmgetimage(monitor) + if self.shm_status != ShmStatus.AVAILABLE: + self.shm_status = ShmStatus.AVAILABLE + self.performance_status.append("MIT-SHM is working correctly.") + except XProtoError as e: + if self.shm_status != ShmStatus.UNKNOWN: + # We know XShmGetImage works, because it worked earlier. Reraise the error. + raise + + # Should we engage the fallback path? In almost all cases, if XShmGetImage failed at this stage (after + # all our testing in __init__), XGetImage will also fail. This could mean that the user sent an + # out-of-bounds request. In more exotic situations, some rare X servers disallow screen capture + # altogether: security-hardened servers, for instance, or some XPrint servers. But let's make sure, by + # testing the same request through XGetImage. + try: + rv = super()._grab_xgetimage(monitor) + except XProtoError: # noqa: TRY203 + # The XGetImage also failed, so we don't know anything about whether XShmGetImage is usable. Maybe + # the user sent an out-of-bounds request. Maybe it's a security-hardened server. We're not sure what + # the problem is. So, if XGetImage failed, we re-raise that error (the one from XShmGetImage will be + # attached as __context__), but we won't update the shm_status yet. (Technically, our except:raise + # clause here is redundant; it's just for clarity, to hold this comment.) + raise + + # Using XShmGetImage failed, and using XGetImage worked. Use XGetImage in the future. + self._shm_report_issue("MIT-SHM GetImage failed", e) + self.shm_status = ShmStatus.UNAVAILABLE + self._shutdown_shm() + + return rv diff --git a/src/mss/models.py b/src/mss/models.py index e756d629..aa79c620 100644 --- a/src/mss/models.py +++ b/src/mss/models.py @@ -1,23 +1,33 @@ -"""This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss. -""" +# This is part of the MSS Python's module. +# Source: https://github.com/BoboTiG/python-mss. +from __future__ import annotations -from typing import Any, Dict, List, NamedTuple, Tuple +from typing import TYPE_CHECKING, Any, NamedTuple -Monitor = Dict[str, int] -Monitors = List[Monitor] +# TODO @BoboTiG: https://github.com/BoboTiG/python-mss/issues/470 +# Change this to a proper Monitor class in next major release. +Monitor = dict[str, Any] +Monitors = list[Monitor] -Pixel = Tuple[int, int, int] -Pixels = List[Tuple[Pixel, ...]] +Pixel = tuple[int, int, int] +Pixels = list[tuple[Pixel, ...]] -CFunctions = Dict[str, Tuple[str, List[Any], Any]] +if TYPE_CHECKING: + from typing import Callable + + CFunctions = dict[str, tuple[str, list[Any], Any]] + CFunctionsErrChecked = dict[str, tuple[str, list[Any], Any, Callable | None]] class Pos(NamedTuple): + #: The horizontal X coordinate of the position. left: int + #: The vertical Y coordinate of the position. top: int class Size(NamedTuple): + #: The horizontal X width. width: int + #: The vertical Y height. height: int diff --git a/src/mss/screenshot.py b/src/mss/screenshot.py index 2d5f72a5..5edb08d7 100644 --- a/src/mss/screenshot.py +++ b/src/mss/screenshot.py @@ -1,19 +1,20 @@ -"""This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss. -""" +# This is part of the MSS Python's module. +# Source: https://github.com/BoboTiG/python-mss. + from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING from mss.exception import ScreenShotError from mss.models import Monitor, Pixel, Pixels, Pos, Size if TYPE_CHECKING: from collections.abc import Iterator + from typing import Any class ScreenShot: - """Screen shot object. + """Screenshot object. .. note:: @@ -29,23 +30,34 @@ def __init__(self, data: bytearray, monitor: Monitor, /, *, size: Size | None = #: Bytearray of the raw BGRA pixels retrieved by ctypes #: OS independent implementations. - self.raw = data + self.raw: bytearray = data - #: NamedTuple of the screen shot coordinates. - self.pos = Pos(monitor["left"], monitor["top"]) + #: NamedTuple of the screenshot coordinates. + self.pos: Pos = Pos(monitor["left"], monitor["top"]) - #: NamedTuple of the screen shot size. - self.size = Size(monitor["width"], monitor["height"]) if size is None else size + #: NamedTuple of the screenshot size. + self.size: Size = Size(monitor["width"], monitor["height"]) if size is None else size def __repr__(self) -> str: return f"<{type(self).__name__} pos={self.left},{self.top} size={self.width}x{self.height}>" @property - def __array_interface__(self) -> Dict[str, Any]: - """Numpy array interface support. - It uses raw data in BGRA form. + def __array_interface__(self) -> dict[str, Any]: + """NumPy array interface support. + + This is used by NumPy, many SciPy projects, CuPy, PyTorch (via + ``torch.from_numpy``), TensorFlow (via ``tf.convert_to_tensor``), + JAX (via ``jax.numpy.asarray``), Pandas, scikit-learn, Matplotlib, + some OpenCV functions, and others. This allows you to pass a + :class:`ScreenShot` instance directly to these libraries without + needing to convert it first. + + This is in HWC order, with 4 channels (BGRA). - See https://docs.scipy.org/doc/numpy/reference/arrays.interface.html + .. seealso:: + + https://numpy.org/doc/stable/reference/arrays.interface.html + The NumPy array interface protocol specification """ return { "version": 3, @@ -56,39 +68,55 @@ def __array_interface__(self) -> Dict[str, Any]: @classmethod def from_size(cls: type[ScreenShot], data: bytearray, width: int, height: int, /) -> ScreenShot: - """Instantiate a new class given only screen shot's data and size.""" + """Instantiate a new class given only screenshot's data and size.""" monitor = {"left": 0, "top": 0, "width": width, "height": height} return cls(data, monitor) @property def bgra(self) -> bytes: - """BGRA values from the BGRA raw pixels.""" - return bytes(self.raw) + """BGRx values from the BGRx raw pixels. - @property - def height(self) -> int: - """Convenient accessor to the height size.""" - return self.size.height + The format is a bytes object with BGRxBGRx... sequence. A specific + pixel can be accessed as + ``bgra[(y * width + x) * 4:(y * width + x) * 4 + 4].`` - @property - def left(self) -> int: - """Convenient accessor to the left position.""" - return self.pos.left + .. note:: + While the name is ``bgra``, the alpha channel may or may not be + valid. + """ + return bytes(self.raw) @property def pixels(self) -> Pixels: - """:return list: RGB tuples.""" + """RGB tuples. + + The format is a list of rows. Each row is a list of pixels. + Each pixel is a tuple of (R, G, B). + """ if not self.__pixels: rgb_tuples: Iterator[Pixel] = zip(self.raw[2::4], self.raw[1::4], self.raw[::4]) self.__pixels = list(zip(*[iter(rgb_tuples)] * self.width)) return self.__pixels + def pixel(self, coord_x: int, coord_y: int) -> Pixel: + """Return the pixel value at a given position. + + :returns: A tuple of (R, G, B) values. + """ + try: + return self.pixels[coord_y][coord_x] + except IndexError as exc: + msg = f"Pixel location ({coord_x}, {coord_y}) is out of range." + raise ScreenShotError(msg) from exc + @property def rgb(self) -> bytes: """Compute RGB values from the BGRA raw pixels. - :return bytes: RGB pixels. + The format is a bytes object with BGRBGR... sequence. A specific + pixel can be accessed as + ``rgb[(y * width + x) * 3:(y * width + x) * 3 + 3]``. """ if not self.__rgb: rgb = bytearray(self.height * self.width * 3) @@ -105,20 +133,17 @@ def top(self) -> int: """Convenient accessor to the top position.""" return self.pos.top + @property + def left(self) -> int: + """Convenient accessor to the left position.""" + return self.pos.left + @property def width(self) -> int: """Convenient accessor to the width size.""" return self.size.width - def pixel(self, coord_x: int, coord_y: int) -> Pixel: - """Returns the pixel value at a given position. - - :param int coord_x: The x coordinate. - :param int coord_y: The y coordinate. - :return tuple: The pixel value as (R, G, B). - """ - try: - return self.pixels[coord_y][coord_x] - except IndexError as exc: - msg = f"Pixel location ({coord_x}, {coord_y}) is out of range." - raise ScreenShotError(msg) from exc + @property + def height(self) -> int: + """Convenient accessor to the height size.""" + return self.size.height diff --git a/src/mss/tools.py b/src/mss/tools.py index 316939cd..9e4b7e9f 100644 --- a/src/mss/tools.py +++ b/src/mss/tools.py @@ -1,23 +1,161 @@ -"""This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss. -""" +# This is part of the MSS Python's module. +# Source: https://github.com/BoboTiG/python-mss. + from __future__ import annotations import os import struct import zlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + +_EDID_BLOCK_LEN = 128 +_EDID_SERIAL_NUMBER_NOT_SET = 0 # The serial number field is unused +_EDID_MANUFACTURE_WEEK_YEAR_IS_MODEL = 0xFF # The year field is for the model year, not the year of manufacture. +_EDID_MANUFACTURE_WEEK_UNKNOWN = 0 # Only the year of manufacture, not the week, is known. +_EDID_YEAR_BASE = 1990 # The starting year for EDID years +_EDID_DESCR_OFFSETS = [0x48, 0x5A, 0x6C] # Display descriptor definition locations in the base block +_EDID_DESCR_LEN = 18 +_EDID_DESCR_ZERO_LOCS = [0, 1, 2, 4] # Locations that must be 0 to mark a display descriptor definition +_EDID_DESCR_TAG_LOC = 3 # Location of display descriptor tag +_EDID_DESCR_TAG_SN = 0xFF # Descriptor is a string serial number +_EDID_DESCR_TAG_NAME = 0xFC # Descriptor is a string model name +_EDID_DESCR_STR_LOC = slice(5, 18) # Location of string in a display descriptor + + +def parse_edid(edid_data: bytes) -> dict: + """Parse a monitor's EDID block. + + Many fields are currently ignored, but may be added in the future. + + If the EDID block cannot be parsed, this returns an empty dict. + + The dict defines the following fields. Any of these may be + missing, if the EDID block does not define them. + + - id_legacy (str): The legacy monitor ID, used in a number of + APIs. This is simply f"{manufacturer}{product_code:04X}". + Those subfields are not part of the returned dict, but are + nominally described as: + + - manufacturer (str): A three-letter, all-uppercase code + specifying the manufacturer's legacy PnP ID. The registry is + managed by UEFI forum. + - product_code (int): A 16-bit product code. This is typically + displayed as four hex digits if rendered to a string. + + - serial_number (str | int): Serial number of the monitor. EDID + block may provide this as an int, string, or both; the string + version is preferred. + - manufacture_week (int): The week, 1-54, of manufacture. This + may not be populated, even if the year is. (The way the weeks + are numbered is up to the manufacturer.) + - manufacture_year (int): The year, 1990 or later, of manufacture. + - model_year (int): The year, 1990 or later, that the model was + released. This is used if the manufacturer doesn't want to + update their EDID block each year; the manufacture_year field is + more common. + - display_name (str): The monitor's model. This is the preferred + value for display. If this field is not present, then id_legacy + is a distant second. + Currently, the serial_number and name fields are always in ASCII. + This function doesn't currently try to implement the + internationalization extensions defined in the VESA LS-EXT + standard. However, we may in the future. -def to_png(data: bytes, size: tuple[int, int], /, *, level: int = 6, output: str | None = None) -> bytes | None: + We also don't currently inspect the extension blocks. The name + and serial number can be in CTA-861 extension blocks; I'll need to + see how common that is. + """ + # See also https://glenwing.github.io/docs/ for a lot of the relevant specs. + + if len(edid_data) < _EDID_BLOCK_LEN: + # Too short + return {} + + # Get the basic identification information from the start of the + # header. This has been part of EDID for a very long time. + block0 = edid_data[:_EDID_BLOCK_LEN] + if sum(block0) % 256 != 0: + # Checksum failure + return {} + + ( + header, + id_manufacturer_msb, + id_manufacturer_lsb, + id_product_code, + id_serial_number, + manufacture_week, + manufacture_year, + _edid_version, + _edid_revision, + _ext_count, + ) = struct.unpack("<8s2BHIBBBB106xBx", block0) + + if header != b"\x00\xff\xff\xff\xff\xff\xff\x00": + # Header incorrect + return {} + id_manufacturer_packed = id_manufacturer_msb << 8 | id_manufacturer_lsb + id_manufacturer = ( + chr(((id_manufacturer_packed >> 10) % 32) + 64) + + chr(((id_manufacturer_packed >> 5) % 32) + 64) + + chr((id_manufacturer_packed % 32) + 64) + ) + rv: dict[str, int | str] = { + "id_legacy": f"{id_manufacturer}{id_product_code:04X}", + } + if id_serial_number != _EDID_SERIAL_NUMBER_NOT_SET: + rv["serial_number"] = id_serial_number + if manufacture_week == _EDID_MANUFACTURE_WEEK_YEAR_IS_MODEL: + rv["model_year"] = manufacture_year + _EDID_YEAR_BASE + else: + if manufacture_week != _EDID_MANUFACTURE_WEEK_UNKNOWN: + rv["manufacture_week"] = manufacture_week + rv["manufacture_year"] = manufacture_year + _EDID_YEAR_BASE + + # Read the display descriptor definitions, which can have more useful information. + for descr_offset in _EDID_DESCR_OFFSETS: + descr = block0[descr_offset : descr_offset + _EDID_DESCR_LEN] + if any(descr[field_offset] != 0 for field_offset in _EDID_DESCR_ZERO_LOCS): + # Not a display descriptor definition + continue + # Check the tag in descr[3]. + # These strings are in ASCII, optionally terminated by \x0A then right-padded with \x20. In case a + # manufacturer got it a little wrong, we ignore everything after \x0A, and we also strip trailing \x20. (The + # spec requires the \x0A, but some manufacturers don't follow that.) + if descr[_EDID_DESCR_TAG_LOC] == _EDID_DESCR_TAG_SN: # Serial number + sn = descr[_EDID_DESCR_STR_LOC] + sn, _, _ = sn.partition(b"\x0a") + sn = sn.rstrip(b" ") + rv["serial_number"] = sn.decode("ascii", errors="replace") + elif descr[_EDID_DESCR_TAG_LOC] == _EDID_DESCR_TAG_NAME: # Name + name = descr[_EDID_DESCR_STR_LOC] + name, _, _ = name.partition(b"\x0a") + name = name.rstrip(b" ") + rv["display_name"] = name.decode("ascii", errors="replace") + + return rv + + +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. :param bytes data: RGBRGB...RGB data. :param tuple size: The (width, height) pair. - :param int level: PNG compression level. + :param int level: PNG compression level (see :py:func:`zlib.compress()` for details). :param str output: Output file name. - """ + .. versionadded:: 3.0.0 + + .. versionchanged:: 3.2.0 + Added the ``level`` keyword argument to control the PNG compression level. + """ pack = struct.pack crc32 = zlib.crc32 @@ -48,7 +186,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 fab27942..0db68173 100644 --- a/src/mss/windows.py +++ b/src/mss/windows.py @@ -1,43 +1,73 @@ -"""This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss. +"""Windows GDI-based backend for MSS. + +Uses user32/gdi32 APIs to capture the desktop and enumerate monitors. +This implementation uses CreateDIBSection for direct memory access to pixel data. """ + from __future__ import annotations import ctypes import sys -from ctypes import POINTER, WINFUNCTYPE, Structure, c_int, c_void_p +import warnings +from ctypes import POINTER, WINFUNCTYPE, Structure, WinError, _Pointer from ctypes.wintypes import ( BOOL, - DOUBLE, + BYTE, DWORD, + HANDLE, HBITMAP, HDC, HGDIOBJ, + HMONITOR, HWND, INT, LONG, LPARAM, LPRECT, + LPVOID, RECT, UINT, WORD, ) -from threading import local -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from mss.base import MSSBase +from mss.base import MSS as _MSS +from mss.base import MSSImplementation from mss.exception import ScreenShotError if TYPE_CHECKING: - from mss.models import CFunctions, Monitor - from mss.screenshot import ScreenShot + from typing import Any, Callable + + from mss.models import CFunctionsErrChecked, Monitor, Monitors __all__ = ("MSS",) +BACKENDS = ["default"] + +class MSS(_MSS): + """Deprecated Windows compatibility constructor. + + Use :class:`mss.MSS` instead. + """ + + def __init__(self, /, **kwargs: Any) -> None: + # TODO(jholveck): #493 Remove compatibility constructor after 10.x transition period. + warnings.warn( + "mss.windows.MSS is deprecated and will be removed in 11.0; use mss.MSS instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) + + +LPCRECT = POINTER(RECT) # Actually a const pointer, but ctypes has no const. CAPTUREBLT = 0x40000000 DIB_RGB_COLORS = 0 SRCCOPY = 0x00CC0020 +CCHDEVICENAME = 32 +MONITORINFOF_PRIMARY = 0x01 +EDD_GET_DEVICE_INTERFACE_NAME = 0x00000001 class BITMAPINFOHEADER(Structure): @@ -61,10 +91,63 @@ class BITMAPINFOHEADER(Structure): class BITMAPINFO(Structure): """Structure that defines the dimensions and color information for a DIB.""" - _fields_ = (("bmiHeader", BITMAPINFOHEADER), ("bmiColors", DWORD * 3)) + # The bmiColors entry is variable length, but it's unused the way we do things. We declare it to be four bytes, + # which is how it's declared in C. + _fields_ = (("bmiHeader", BITMAPINFOHEADER), ("bmiColors", BYTE * 4)) -MONITORNUMPROC = WINFUNCTYPE(INT, DWORD, DWORD, POINTER(RECT), DOUBLE) +class MONITORINFOEXW(Structure): + """Extended monitor information structure. + https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-monitorinfoexw + """ + + _fields_ = ( + ("cbSize", DWORD), + ("rcMonitor", RECT), + ("rcWork", RECT), + ("dwFlags", DWORD), + ("szDevice", WORD * CCHDEVICENAME), + ) + + +class DISPLAY_DEVICEW(Structure): # noqa: N801 + """Display device information structure. + https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-display_devicew + """ + + _fields_ = ( + ("cb", DWORD), + ("DeviceName", WORD * 32), + ("DeviceString", WORD * 128), + ("StateFlags", DWORD), + ("DeviceID", WORD * 128), + ("DeviceKey", WORD * 128), + ) + + +MONITORNUMPROC = WINFUNCTYPE(BOOL, HMONITOR, HDC, POINTER(RECT), LPARAM) + + +def _errcheck(result: BOOL | _Pointer, func: Callable, arguments: tuple) -> tuple: + """If the result is zero, raise an exception.""" + if not result: + # Notably, the errno that is in winerror may not be relevant. Use the winerror and strerror attributes + # instead. + winerror = WinError() + details = { + "func": func.__name__, + "args": arguments, + "error_code": winerror.winerror, + "error_msg": winerror.strerror, + } + if winerror.winerror == 0: + # Some functions return NULL/0 on failure without setting last error. (Example: CreateDIBSection + # with an invalid HDC.) + msg = f"Windows graphics function failed (no error provided): {func.__name__}" + raise ScreenShotError(msg, details=details) + msg = f"Windows graphics function failed: {func.__name__}: {winerror.strerror}" + raise ScreenShotError(msg, details=details) from winerror + return arguments # C functions that will be initialised later. @@ -72,66 +155,107 @@ class BITMAPINFO(Structure): # Available attr: gdi32, user32. # # Note: keep it sorted by cfunction. -CFUNCTIONS: CFunctions = { - # 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), - "DeleteDC": ("gdi32", [HDC], HDC), - "DeleteObject": ("gdi32", [HGDIOBJ], INT), - "EnumDisplayMonitors": ("user32", [HDC, c_void_p, MONITORNUMPROC, LPARAM], BOOL), - "GetDeviceCaps": ("gdi32", [HWND, INT], INT), - "GetDIBits": ("gdi32", [HDC, HBITMAP, UINT, UINT, c_void_p, POINTER(BITMAPINFO), UINT], BOOL), - "GetSystemMetrics": ("user32", [INT], INT), - "GetWindowDC": ("user32", [HWND], HDC), - "ReleaseDC": ("user32", [HWND, HDC], c_int), - "SelectObject": ("gdi32", [HDC, HGDIOBJ], HGDIOBJ), +CFUNCTIONS: CFunctionsErrChecked = { + # Syntax: cfunction: (attr, argtypes, restype, errcheck) + "BitBlt": ("gdi32", [HDC, INT, INT, INT, INT, HDC, INT, INT, DWORD], BOOL, _errcheck), + "CreateCompatibleDC": ("gdi32", [HDC], HDC, _errcheck), + # CreateDIBSection: ppvBits (4th param) receives a pointer to the DIB pixel data. + # hSection is NULL and offset is 0 to have the system allocate the memory. + "CreateDIBSection": ("gdi32", [HDC, POINTER(BITMAPINFO), UINT, POINTER(LPVOID), HANDLE, DWORD], HBITMAP, _errcheck), + "DeleteDC": ("gdi32", [HDC], HDC, _errcheck), + "DeleteObject": ("gdi32", [HGDIOBJ], BOOL, _errcheck), + "EnumDisplayDevicesW": ("user32", [POINTER(WORD), DWORD, POINTER(DISPLAY_DEVICEW), DWORD], BOOL, None), + "EnumDisplayMonitors": ("user32", [HDC, LPCRECT, MONITORNUMPROC, LPARAM], BOOL, _errcheck), + # GdiFlush flushes the calling thread's current batch of GDI operations. + # This ensures DIB memory is fully updated before reading. + "GdiFlush": ("gdi32", [], BOOL, None), + # While GetSystemMetrics will return 0 if the parameter is invalid, it will also sometimes return 0 if the + # parameter is valid but the value is actually 0 (e.g., SM_CLEANBOOT on a normal boot). Thus, we do not attach an + # errcheck function here. + "GetSystemMetrics": ("user32", [INT], INT, None), + "GetMonitorInfoW": ("user32", [HMONITOR, POINTER(MONITORINFOEXW)], BOOL, _errcheck), + "GetWindowDC": ("user32", [HWND], HDC, _errcheck), + "ReleaseDC": ("user32", [HWND, HDC], INT, _errcheck), + # SelectObject returns NULL on error the way we call it. If it's called to select a region, it returns HGDI_ERROR + # on error. + "SelectObject": ("gdi32", [HDC, HGDIOBJ], HGDIOBJ, _errcheck), } -class MSS(MSSBase): - """Multiple ScreenShots implementation for Microsoft Windows.""" +class MSSImplWindows(MSSImplementation): + """Multiple ScreenShots implementation for Microsoft Windows. - __slots__ = {"gdi32", "user32", "_handles"} + This implementation uses CreateDIBSection for direct memory access to pixel data, + which eliminates the need for GetDIBits. The DIB pixel data is written directly + to system-managed memory that we can read from. - def __init__(self, /, **kwargs: Any) -> None: - """Windows initialisations.""" - super().__init__(**kwargs) + This has no Windows-specific constructor parameters. + + .. seealso:: + + :py:class:`mss.MSS` + Lists constructor parameters. + """ + + __slots__ = { + "_bmi", + "_dib", + "_dib_array", + "_dib_bits", + "_memdc", + "_region_width_height", + "_srcdc", + "gdi32", + "user32", + } + + def __init__(self, *, backend: str = "default") -> None: + super().__init__() + + if backend != "default": + msg = 'The only valid backend on this platform is "default".' + raise ScreenShotError(msg) - self.user32 = ctypes.WinDLL("user32") - self.gdi32 = ctypes.WinDLL("gdi32") + # user32 and gdi32 should not be changed after initialization. + self.user32 = ctypes.WinDLL("user32", use_last_error=True) + self.gdi32 = ctypes.WinDLL("gdi32", use_last_error=True) self._set_cfunctions() self._set_dpi_awareness() - # Available thread-specific variables - self._handles = local() - self._handles.region_width_height = (0, 0) - self._handles.bmp = None - self._handles.srcdc = self.user32.GetWindowDC(0) - self._handles.memdc = self.gdi32.CreateCompatibleDC(self._handles.srcdc) + # Available instance-specific variables + self._region_width_height: tuple[int, int] | None = None + self._dib: HBITMAP | None = None + self._dib_bits: LPVOID = LPVOID() # Pointer to DIB pixel data + self._dib_array: ctypes.Array[ctypes.c_char] | None = None # Cached array view of DIB memory + self._srcdc = self.user32.GetWindowDC(0) + self._memdc = self.gdi32.CreateCompatibleDC(self._srcdc) bmi = BITMAPINFO() bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + # biWidth and biHeight are set in grab(). bmi.bmiHeader.biPlanes = 1 # Always 1 - bmi.bmiHeader.biBitCount = 32 # See grab.__doc__ [2] + bmi.bmiHeader.biBitCount = 32 # 32-bit RGBX bmi.bmiHeader.biCompression = 0 # 0 = BI_RGB (no compression) - bmi.bmiHeader.biClrUsed = 0 # See grab.__doc__ [3] - bmi.bmiHeader.biClrImportant = 0 # See grab.__doc__ [3] - self._handles.bmi = bmi + bmi.bmiHeader.biSizeImage = 0 # Windows infers the size + bmi.bmiHeader.biXPelsPerMeter = 0 # Unspecified + bmi.bmiHeader.biYPelsPerMeter = 0 # Unspecified + bmi.bmiHeader.biClrUsed = 0 + bmi.bmiHeader.biClrImportant = 0 + self._bmi = bmi def close(self) -> None: # Clean-up - if self._handles.bmp: - self.gdi32.DeleteObject(self._handles.bmp) - self._handles.bmp = None + if self._dib: + self.gdi32.DeleteObject(self._dib) + self._dib = None - if self._handles.memdc: - self.gdi32.DeleteDC(self._handles.memdc) - self._handles.memdc = None + if self._memdc: + self.gdi32.DeleteDC(self._memdc) + self._memdc = None - if self._handles.srcdc: - self.user32.ReleaseDC(0, self._handles.srcdc) - self._handles.srcdc = None + if self._srcdc: + self.user32.ReleaseDC(0, self._srcdc) + self._srcdc = None def _set_cfunctions(self) -> None: """Set all ctypes functions and attach them to attributes.""" @@ -140,8 +264,8 @@ def _set_cfunctions(self) -> None: "gdi32": self.gdi32, "user32": self.user32, } - for func, (attr, argtypes, restype) in CFUNCTIONS.items(): - cfactory(attrs[attr], func, argtypes, restype) + for func, (attr, argtypes, restype, errcheck) in CFUNCTIONS.items(): + cfactory(attrs[attr], func, argtypes, restype, errcheck) def _set_dpi_awareness(self) -> None: """Set DPI awareness to capture full screen on Hi-DPI monitors.""" @@ -157,14 +281,15 @@ def _set_dpi_awareness(self) -> None: # Windows Vista, 7, 8, and Server 2012 self.user32.SetProcessDPIAware() - def _monitors_impl(self) -> None: - """Get positions of monitors. It will populate self._monitors.""" + def monitors(self) -> Monitors: int_ = int user32 = self.user32 get_system_metrics = user32.GetSystemMetrics + monitors = [] + # All monitors - self._monitors.append( + monitors.append( { "left": int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN "top": int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN @@ -174,77 +299,117 @@ def _monitors_impl(self) -> None: ) # Each monitor - def _callback(_monitor: int, _data: HDC, rect: LPRECT, _dc: LPARAM) -> int: + @MONITORNUMPROC + def callback(hmonitor: HMONITOR, _data: HDC, rect: LPRECT, _dc: LPARAM) -> bool: """Callback for monitorenumproc() function, it will return a RECT with appropriate values. """ + # Get monitor info to check if it's the primary monitor and get device name + info = MONITORINFOEXW() + info.cbSize = ctypes.sizeof(MONITORINFOEXW) + user32.GetMonitorInfoW(hmonitor, ctypes.byref(info)) rct = rect.contents - self._monitors.append( - { - "left": int_(rct.left), - "top": int_(rct.top), - "width": int_(rct.right) - int_(rct.left), - "height": int_(rct.bottom) - int_(rct.top), - }, - ) - return 1 - - callback = MONITORNUMPROC(_callback) - user32.EnumDisplayMonitors(0, 0, callback, 0) - - def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: - """Retrieve all pixels from a monitor. Pixels have to be RGB. - - In the code, there are a few interesting things: - - [1] bmi.bmiHeader.biHeight = -height - - A bottom-up DIB is specified by setting the height to a - positive number, while a top-down DIB is specified by - setting the height to a negative number. - https://msdn.microsoft.com/en-us/library/ms787796.aspx - https://msdn.microsoft.com/en-us/library/dd144879%28v=vs.85%29.aspx - - - [2] bmi.bmiHeader.biBitCount = 32 - image_data = create_string_buffer(height * width * 4) - - We grab the image in RGBX mode, so that each word is 32bit - and we have no striding. - Inspired by https://github.com/zoofIO/flexx - - - [3] bmi.bmiHeader.biClrUsed = 0 - bmi.bmiHeader.biClrImportant = 0 - - When biClrUsed and biClrImportant are set to zero, there - is "no" color table, so we can read the pixels of the bitmap - retrieved by gdi32.GetDIBits() as a sequence of RGB values. - Thanks to http://stackoverflow.com/a/3688682 + left = int_(rct.left) + top = int_(rct.top) + # Check the dwFlags field for MONITORINFOF_PRIMARY + is_primary = bool(info.dwFlags & MONITORINFOF_PRIMARY) + display_device = DISPLAY_DEVICEW() + display_device.cb = ctypes.sizeof(DISPLAY_DEVICEW) + + # EnumDisplayDevicesW can get friendly name (e.g. "Generic PnP Monitor") + device_string: str | None = None + if user32.EnumDisplayDevicesW( + ctypes.cast(ctypes.addressof(info.szDevice), POINTER(WORD)), + 0, + ctypes.byref(display_device), + 0, + ): + device_string = ctypes.wstring_at(ctypes.addressof(display_device.DeviceString)) + + # Get device interface name (stable per-physical-monitor ID) when supported + unique_id: str | None = None + if user32.EnumDisplayDevicesW( + ctypes.cast(ctypes.addressof(info.szDevice), POINTER(WORD)), + 0, + ctypes.byref(display_device), + EDD_GET_DEVICE_INTERFACE_NAME, + ): + unique_id = ctypes.wstring_at(ctypes.addressof(display_device.DeviceID)) + + mon_dict: dict[str, Any] = { + "left": left, + "top": top, + "width": int_(rct.right) - left, + "height": int_(rct.bottom) - top, + "is_primary": is_primary, + } + if device_string is not None: + mon_dict["name"] = device_string + if unique_id is not None: + mon_dict["unique_id"] = unique_id + monitors.append(mon_dict) + return True + + user32.EnumDisplayMonitors(0, None, callback, 0) + + return monitors + + def grab(self, monitor: Monitor, /) -> bytearray: + """Retrieve all pixels from a monitor using CreateDIBSection. + + CreateDIBSection creates a DIB with system-managed memory backing, + allowing BitBlt to write directly to memory we can read. This eliminates + the need for a separate GetDIBits call. + + Note on biHeight: A bottom-up DIB is specified by setting the height to a + positive number, while a top-down DIB is specified by setting the height + to a negative number. We use negative height for top-down orientation. + https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapinfoheader + https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-createdibsection """ - srcdc, memdc = self._handles.srcdc, self._handles.memdc + srcdc, memdc = self._srcdc, self._memdc gdi = self.gdi32 width, height = monitor["width"], monitor["height"] - if self._handles.region_width_height != (width, height): - self._handles.region_width_height = (width, height) - self._handles.bmi.bmiHeader.biWidth = width - self._handles.bmi.bmiHeader.biHeight = -height # Why minus? [1] - self._handles.data = ctypes.create_string_buffer(width * height * 4) # [2] - if self._handles.bmp: - gdi.DeleteObject(self._handles.bmp) - self._handles.bmp = gdi.CreateCompatibleBitmap(srcdc, width, height) - gdi.SelectObject(memdc, self._handles.bmp) + if self._region_width_height != (width, height): + self._region_width_height = (width, height) + self._bmi.bmiHeader.biWidth = width + self._bmi.bmiHeader.biHeight = -height # Negative for top-down DIB + + if self._dib: + gdi.DeleteObject(self._dib) + self._dib = None + + # CreateDIBSection creates the DIB and returns a pointer to the pixel data + self._dib_bits = LPVOID() + self._dib = gdi.CreateDIBSection( + memdc, + self._bmi, + DIB_RGB_COLORS, + ctypes.byref(self._dib_bits), + None, # hSection = NULL (system allocates memory) + 0, # offset = 0 + ) + gdi.SelectObject(memdc, self._dib) + + # Create a ctypes array type that maps directly to the DIB memory. + # This avoids the overhead of ctypes.string_at() creating an intermediate bytes object. + size = width * height * 4 + array_type = ctypes.c_char * size + self._dib_array = ctypes.cast(self._dib_bits, POINTER(array_type)).contents + # BitBlt copies screen content directly into the DIB's memory gdi.BitBlt(memdc, 0, 0, width, height, srcdc, monitor["left"], monitor["top"], SRCCOPY | CAPTUREBLT) - bits = gdi.GetDIBits(memdc, self._handles.bmp, 0, height, self._handles.data, self._handles.bmi, DIB_RGB_COLORS) - if bits != height: - msg = "gdi32.GetDIBits() failed." - raise ScreenShotError(msg) - return self.cls_image(bytearray(self._handles.data), monitor) + # Flush GDI operations to ensure DIB memory is fully updated before reading. + # This ensures the BitBlt has completed before we access the memory. + gdi.GdiFlush() + + # Read directly from DIB memory via the cached array view + assert self._dib_array is not None # noqa: S101 for type checker + return bytearray(self._dib_array) - def _cursor_impl(self) -> ScreenShot | None: + def cursor(self) -> None: """Retrieve all cursor data. Pixels have to be RGB.""" - return None + return diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/bench_bgra2rgb.py b/src/tests/bench_bgra2rgb.py index 49043f5c..27eadb0d 100644 --- a/src/tests/bench_bgra2rgb.py +++ b/src/tests/bench_bgra2rgb.py @@ -27,13 +27,15 @@ numpy_flip 25 numpy_slice 22 """ + import time -import mss import numpy as np -from mss.screenshot import ScreenShot from PIL import Image +import mss +from mss.screenshot import ScreenShot + def mss_rgb(im: ScreenShot) -> bytes: return im.rgb @@ -57,7 +59,7 @@ def pil_frombytes(im: ScreenShot) -> bytes: def benchmark() -> None: - with mss.mss() as sct: + with mss.MSS() as sct: im = sct.grab(sct.monitors[0]) for func in ( pil_frombytes, diff --git a/src/tests/bench_general.py b/src/tests/bench_general.py index cec26d52..d7d223af 100644 --- a/src/tests/bench_general.py +++ b/src/tests/bench_general.py @@ -24,6 +24,7 @@ access_rgb 574 712 +24.04 output 139 188 +35.25 """ + from __future__ import annotations from time import time @@ -35,26 +36,26 @@ if TYPE_CHECKING: from collections.abc import Callable - from mss.base import MSSBase + from mss import MSS from mss.screenshot import ScreenShot -def grab(sct: MSSBase) -> ScreenShot: +def grab(sct: MSS) -> ScreenShot: monitor = {"top": 144, "left": 80, "width": 1397, "height": 782} return sct.grab(monitor) -def access_rgb(sct: MSSBase) -> bytes: +def access_rgb(sct: MSS) -> bytes: im = grab(sct) return im.rgb -def output(sct: MSSBase, filename: str | None = None) -> None: +def output(sct: MSS, filename: str | None = None) -> None: rgb = access_rgb(sct) mss.tools.to_png(rgb, (1397, 782), output=filename) -def save(sct: MSSBase) -> None: +def save(sct: MSS) -> None: output(sct, filename="screenshot.png") @@ -62,7 +63,7 @@ def benchmark(func: Callable) -> None: count = 0 start = time() - with mss.mss() as sct: + with mss.MSS() as sct: while (time() - start) % 60 < 10: count += 1 func(sct) diff --git a/src/tests/bench_grab_windows.py b/src/tests/bench_grab_windows.py new file mode 100644 index 00000000..b666ab5e --- /dev/null +++ b/src/tests/bench_grab_windows.py @@ -0,0 +1,229 @@ +"""Quick benchmark for Windows grab performance. + +This script measures the performance of the Windows GDI grab implementation +using CreateDIBSection. + +Run with: python -m tests.bench_grab_windows + python -m tests.bench_grab_windows timing + python -m tests.bench_grab_windows raw +""" + +from __future__ import annotations + +import sys +from time import perf_counter + +import mss + +ITERATIONS = 500 +WARMUP_ITERATIONS = 10 + + +def benchmark_grab() -> tuple[float, float]: + """Benchmark the grab operation on the primary monitor. + + Returns (avg_ms, fps) for comparison. + """ + with mss.MSS() as sct: + monitor = sct.monitors[1] # Primary monitor + width, height = monitor["width"], monitor["height"] + + print(f"Platform: {sys.platform}") + print(f"Region: {width}x{height}") + print(f"Iterations: {ITERATIONS}") + print() + + # Warmup - let any JIT/caching settle + for _ in range(WARMUP_ITERATIONS): + sct.grab(monitor) + + # Benchmark + start = perf_counter() + for _ in range(ITERATIONS): + sct.grab(monitor) + elapsed = perf_counter() - start + + avg_ms = elapsed / ITERATIONS * 1000 + fps = ITERATIONS / elapsed + + print(f"Total time: {elapsed:.3f}s") + print(f"Avg per grab: {avg_ms:.2f}ms") + print(f"FPS: {fps:.1f}") + + return avg_ms, fps + + +def benchmark_grab_varying_sizes() -> None: + """Benchmark grab at different region sizes to see scaling behavior.""" + sizes = [ + (100, 100), + (640, 480), + (1280, 720), + (1920, 1080), + ] + + print("\nVarying size benchmark:") + print("-" * 50) + + with mss.MSS() as sct: + for width, height in sizes: + monitor = {"top": 0, "left": 0, "width": width, "height": height} + + # Warmup + for _ in range(WARMUP_ITERATIONS): + sct.grab(monitor) + + # Benchmark + start = perf_counter() + for _ in range(ITERATIONS): + sct.grab(monitor) + elapsed = perf_counter() - start + + avg_ms = elapsed / ITERATIONS * 1000 + fps = ITERATIONS / elapsed + print(f" {width}x{height}: {avg_ms:.2f}ms ({fps:.1f} FPS)") + + +def benchmark_raw_bitblt() -> None: + """Benchmark raw BitBlt to isolate GDI performance from Python overhead.""" + if sys.platform != "win32": + print("Raw BitBlt benchmark is only available on Windows.") + return + + import ctypes # noqa: PLC0415 + from ctypes.wintypes import BOOL, DWORD, HDC, INT # noqa: PLC0415 + + import mss.windows # noqa: PLC0415 + + gdi32 = ctypes.WinDLL("gdi32", use_last_error=True) + + # Get function references (names match Windows API) + bitblt = gdi32.BitBlt + bitblt.argtypes = [HDC, INT, INT, INT, INT, HDC, INT, INT, DWORD] + bitblt.restype = BOOL + + gdiflush = gdi32.GdiFlush + gdiflush.argtypes = [] + gdiflush.restype = BOOL + + srccopy = 0x00CC0020 + captureblt = 0x40000000 + + with mss.MSS() as sct: + assert isinstance(sct._impl, mss.windows.MSSImplWindows) + monitor = sct.monitors[1] + width, height = monitor["width"], monitor["height"] + left, top = monitor["left"], monitor["top"] + + # Force region setup + sct.grab(monitor) + + srcdc = sct._impl._srcdc + memdc = sct._impl._memdc + + print(f"Raw BitBlt benchmark ({width}x{height})") + print("=" * 50) + + # Test with CAPTUREBLT + start = perf_counter() + for _ in range(ITERATIONS): + bitblt(memdc, 0, 0, width, height, srcdc, left, top, srccopy | captureblt) + gdiflush() + elapsed = perf_counter() - start + print(f"With CAPTUREBLT: {elapsed / ITERATIONS * 1000:.2f}ms ({ITERATIONS / elapsed:.1f} FPS)") + + # Test without CAPTUREBLT + start = perf_counter() + for _ in range(ITERATIONS): + bitblt(memdc, 0, 0, width, height, srcdc, left, top, srccopy) + gdiflush() + elapsed = perf_counter() - start + print(f"Without CAPTUREBLT: {elapsed / ITERATIONS * 1000:.2f}ms ({ITERATIONS / elapsed:.1f} FPS)") + + +def analyze_frame_timing() -> None: + """Analyze individual frame timing to detect VSync/DWM patterns.""" + num_samples = 200 + + with mss.MSS() as sct: + monitor = sct.monitors[1] + width, height = monitor["width"], monitor["height"] + + print("Frame timing analysis") + print(f"Region: {width}x{height}") + print(f"Samples: {num_samples}") + print("=" * 50) + + # Warmup + for _ in range(WARMUP_ITERATIONS): + sct.grab(monitor) + + # Collect individual frame times + times: list[float] = [] + prev = perf_counter() + for _ in range(num_samples): + sct.grab(monitor) + now = perf_counter() + times.append((now - prev) * 1000) # Convert to ms + prev = now + + # Analyze the distribution + times.sort() + min_t = times[0] + max_t = times[-1] + avg_t = sum(times) / len(times) + median_t = times[len(times) // 2] + + # Calculate percentiles + p5 = times[int(len(times) * 0.05)] + p95 = times[int(len(times) * 0.95)] + + print("\nTiming distribution:") + print(f" Min: {min_t:.2f}ms") + print(f" 5th %: {p5:.2f}ms") + print(f" Median: {median_t:.2f}ms") + print(f" Avg: {avg_t:.2f}ms") + print(f" 95th %: {p95:.2f}ms") + print(f" Max: {max_t:.2f}ms") + + # Check for VSync patterns + print("\nVSync pattern analysis:") + print(" 60 Hz (16.67ms): ", end="") + near_60hz = sum(1 for t in times if 15 < t < 18) + print(f"{near_60hz}/{num_samples} samples ({near_60hz / num_samples * 100:.0f}%)") + + print(" 30 Hz (33.33ms): ", end="") + near_30hz = sum(1 for t in times if 31 < t < 36) + print(f"{near_30hz}/{num_samples} samples ({near_30hz / num_samples * 100:.0f}%)") + + print(" < 10ms (fast): ", end="") + fast = sum(1 for t in times if t < 10) + print(f"{fast}/{num_samples} samples ({fast / num_samples * 100:.0f}%)") + + # Histogram buckets + print("\nHistogram (ms):") + buckets = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 100] + for i in range(len(buckets) - 1): + lo, hi = buckets[i], buckets[i + 1] + count = sum(1 for t in times if lo <= t < hi) + bar = "#" * (count * 40 // num_samples) + print(f" {lo:3d}-{hi:3d}: {bar} ({count})") + # Overflow bucket + count = sum(1 for t in times if t >= buckets[-1]) + if count > 0: + bar = "#" * (count * 40 // num_samples) + print(f" {buckets[-1]:3d}+ : {bar} ({count})") + + +if __name__ == "__main__": + if len(sys.argv) > 1: + arg = sys.argv[1].lower() + if arg == "raw": + benchmark_raw_bitblt() + sys.exit(0) + if arg == "timing": + analyze_frame_timing() + sys.exit(0) + + benchmark_grab() + benchmark_grab_varying_sizes() diff --git a/src/tests/conftest.py b/src/tests/conftest.py index a97ccc39..7eaa5990 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,16 +1,19 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ -import glob + import os -import platform -from hashlib import md5 +from collections.abc import Callable, Generator +from hashlib import sha256 from pathlib import Path -from typing import Generator +from platform import system +from typing import Any from zipfile import ZipFile import pytest -from mss import mss + +from mss import MSS +from mss.linux import xcb, xlib @pytest.fixture(autouse=True) @@ -26,13 +29,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) @@ -40,25 +43,73 @@ def _before_tests() -> None: purge_files() +@pytest.fixture(autouse=True) +def no_xlib_errors(request: pytest.FixtureRequest) -> None: + system() == "Linux" and ("backend" not in request.fixturenames or request.getfixturevalue("backend") == "xlib") + assert not xlib._ERROR + + +@pytest.fixture(autouse=True) +def reset_xcb_libraries(request: pytest.FixtureRequest) -> Generator[None]: + # We need to test this before we yield, since the backend isn't available afterwards. + xcb_should_reset = system() == "Linux" and ( + "backend" not in request.fixturenames or request.getfixturevalue("backend") == "xcb" + ) + yield None + if xcb_should_reset: + xcb.LIB.reset() + + @pytest.fixture(scope="session") def raw() -> bytes: file = Path(__file__).parent / "res" / "monitor-1024x768.raw.zip" with ZipFile(file) as fh: data = fh.read(file.with_suffix("").name) - assert md5(data).hexdigest() == "125696266e2a8f5240f6bc17e4df98c6" + assert sha256(data).hexdigest() == "d86ed4366d5a882cfe1345de82c87b81aef9f9bf085f4c42acb6f63f3967eccd" return data -@pytest.fixture(scope="session") -def pixel_ratio() -> int: - """Get the pixel, used to adapt test checks.""" - if platform.system().lower() != "darwin": - return 1 - - # Grab a 1x1 screenshot - region = {"top": 0, "left": 0, "width": 1, "height": 1} - - with mss() as sct: - # On macOS with Retina display, the width can be 2 instead of 1 - return sct.grab(region).size[0] +@pytest.fixture(params=["xlib", "xgetimage", "xshmgetimage"] if system() == "Linux" else ["default"]) +def backend(request: pytest.FixtureRequest) -> str: + return request.param + + +@pytest.fixture +def mss_impl(backend: str) -> Callable[..., MSS]: + # We can't just use partial here, since it will read $DISPLAY at the wrong time. This can cause problems, + # depending on just how the fixtures get run. + def impl(*args: Any, **kwargs: Any) -> MSS: + # I'm not really sure if adding an explicit display is needed anymore. It was in a lot of existing code that + # mss_impl replaced, but it should now be the default at this point. I'll have to investigate. + if system() == "Linux": + kwargs = {"display": os.getenv("DISPLAY")} | kwargs + return MSS(*args, backend=backend, **kwargs) + + return impl + + +@pytest.fixture(autouse=True, scope="session") +def inhibit_x11_resets() -> Generator[None, None, None]: + """Ensure that an X11 connection is open during the test session. + + Under X11, when the last client disconnects, the server resets. If + a new client tries to connect before the reset is complete, it may fail. + Since we often run the tests under Xvfb, they're frequently the only + clients. Since our tests run in rapid succession, this combination + can lead to intermittent failures. + + To avoid this, we open a connection at the start of the test session + and keep it open until the end. + """ + if system() != "Linux": + yield + return + + conn, _ = xcb.connect() + try: + yield + finally: + # Some tests may have reset xcb.LIB, so make sure it's currently initialized. + xcb.initialize() + xcb.disconnect(conn) diff --git a/src/tests/test_bgra_to_rgb.py b/src/tests/test_bgra_to_rgb.py index ddd9529e..a481c1f1 100644 --- a/src/tests/test_bgra_to_rgb.py +++ b/src/tests/test_bgra_to_rgb.py @@ -1,7 +1,9 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ + import pytest + from mss.base import ScreenShot diff --git a/src/tests/test_cls_image.py b/src/tests/test_cls_image.py index eb6b8596..16564575 100644 --- a/src/tests/test_cls_image.py +++ b/src/tests/test_cls_image.py @@ -1,10 +1,11 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ -import os + +from collections.abc import Callable from typing import Any -from mss import mss +from mss import MSS from mss.models import Monitor @@ -14,8 +15,8 @@ def __init__(self, data: bytearray, monitor: Monitor, **_: Any) -> None: self.monitor = monitor -def test_custom_cls_image() -> None: - with mss(display=os.getenv("DISPLAY")) as sct: +def test_custom_cls_image(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: sct.cls_image = SimpleScreenShot # type: ignore[assignment] mon1 = sct.monitors[1] image = sct.grab(mon1) diff --git a/src/tests/test_compat_10_1.py b/src/tests/test_compat_10_1.py new file mode 100644 index 00000000..bb23f0bb --- /dev/null +++ b/src/tests/test_compat_10_1.py @@ -0,0 +1,167 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import platform +import warnings +from collections.abc import Callable +from os import getenv +from typing import Protocol, cast + +import pytest + +import mss +from mss import MSS +from mss.base import MSSBase + + +class PlatformModule(Protocol): + MSS: type[MSS] + + +MSSFactory = Callable[[], MSS] +MSSFactoryGetter = Callable[[], MSSFactory] + + +def _factory_from_import_style() -> MSSFactory: + from mss import mss as mss_factory # noqa: PLC0415 + + return cast("MSSFactory", mss_factory) + + +def _factory_from_module_style() -> MSSFactory: + import mss as mss_module # noqa: PLC0415 + + return mss_module.mss + + +def _platform_module() -> PlatformModule: + os_ = platform.system().lower() + + if os_ == "linux": + import mss.linux as mss_platform # noqa: PLC0415 + + return cast("PlatformModule", mss_platform) + if os_ == "darwin": + import mss.darwin as mss_platform # type: ignore[no-redef] # noqa: PLC0415 + + return cast("PlatformModule", mss_platform) + if os_ == "windows": + import mss.windows as mss_platform # type: ignore[no-redef] # noqa: PLC0415 + + return cast("PlatformModule", mss_platform) + msg = f"Unsupported platform for compatibility test: {os_!r}" + raise AssertionError(msg) + + +def _platform_factory_from_import_style() -> type[MSS]: + os_ = platform.system().lower() + + if os_ == "linux": + import mss.linux # noqa: PLC0415 + + return mss.linux.MSS + if os_ == "darwin": + import mss.darwin # noqa: PLC0415 + + return mss.darwin.MSS + if os_ == "windows": + import mss.windows # noqa: PLC0415 + + return mss.windows.MSS + msg = f"Unsupported platform for compatibility test: {os_!r}" + raise AssertionError(msg) + + +@pytest.mark.parametrize( + "factory_getter", + [ + lambda: mss.mss, + _factory_from_import_style, + _factory_from_module_style, + ], +) +def test_mss_factory_documented_styles_return_mssbase(factory_getter: MSSFactoryGetter) -> None: + factory = factory_getter() + + with pytest.warns(DeprecationWarning, match=r"^mss\.mss is deprecated"): + context = factory() + + with context as sct: + assert isinstance(sct, MSSBase) + assert isinstance(sct, MSS) + + +def test_documented_style_platform_import_mss() -> None: + mss_factory = _platform_factory_from_import_style() + + with pytest.warns(DeprecationWarning, match=r"^mss\..*\.MSS is deprecated"): + context = mss_factory() + + with context as sct: + assert isinstance(sct, MSSBase) + + +def test_direct_mss_constructor_has_no_deprecation_warning() -> None: + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always", DeprecationWarning) + with mss.MSS() as sct: + assert isinstance(sct, MSS) + assert not [warning for warning in captured if issubclass(warning.category, DeprecationWarning)] + + +def test_mssbase_alias_stays_compatible() -> None: + # 10.1-compatible typing/import path. + assert MSSBase is MSS + + +def test_platform_mss_constructor_works_on_current_platform() -> None: + mss_platform = _platform_module() + + with pytest.warns(DeprecationWarning, match=r"^mss\..*\.MSS is deprecated"): + sct_context = mss_platform.MSS() + + with sct_context as sct: + assert isinstance(sct, mss_platform.MSS) + assert isinstance(sct, MSSBase) + assert isinstance(sct, MSS) + + +def test_factory_and_platform_constructor_are_compatible_types() -> None: + mss_platform = _platform_module() + + with pytest.warns(DeprecationWarning, match=r"^mss\..*\.MSS is deprecated"): + from_platform_context = mss_platform.MSS() + + with pytest.warns(DeprecationWarning, match=r"^mss\.mss is deprecated"): + from_factory_context = mss.mss() + + with from_factory_context as from_factory, from_platform_context as from_platform: + assert type(from_factory) is MSS + assert type(from_platform) is mss_platform.MSS + assert isinstance(from_platform, MSS) + assert isinstance(from_platform, MSSBase) + + +def test_deprecated_factory_accepts_documented_kwargs() -> None: + """Verify that kwargs are accepted, even if not relevant. + + All 10.1-documented kwargs were accepted on every platform, even + if only meaningful on one. Verify that still works via the + deprecated factory. + """ + kwargs = { + "compression_level": 1, + "with_cursor": True, + "max_displays": 1, + "display": getenv("DISPLAY"), # None on non-Linux + } + + with ( + pytest.warns(DeprecationWarning, match=r"^mss\.mss is deprecated"), + pytest.warns(DeprecationWarning, match=r"is only available on"), + ): + context = mss.mss(**kwargs) + + with context as sct: + assert isinstance(sct, MSSBase) diff --git a/src/tests/test_compat_exports.py b/src/tests/test_compat_exports.py new file mode 100644 index 00000000..92cfefb9 --- /dev/null +++ b/src/tests/test_compat_exports.py @@ -0,0 +1,19 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import mss +import mss.base + + +def test_top_level_export_surface_exists() -> None: + # TODO(jholveck): #493 Remove compatibility-only export checks after 10.x transition period. + assert hasattr(mss, "mss") + assert hasattr(mss, "MSS") + assert hasattr(mss, "ScreenShotError") + assert hasattr(mss, "__version__") + + +def test_mssbase_compat_symbol_exists() -> None: + # TODO(jholveck): #493 Remove compatibility-only export checks after 10.x transition period. + assert hasattr(mss.base, "MSSBase") diff --git a/src/tests/test_compat_linux_api.py b/src/tests/test_compat_linux_api.py new file mode 100644 index 00000000..4360b1f3 --- /dev/null +++ b/src/tests/test_compat_linux_api.py @@ -0,0 +1,30 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import platform + +import pytest + +import mss.linux + + +@pytest.mark.skipif(platform.system().lower() != "linux", reason="GNU/Linux compatibility checks") +def test_linux_10_1_documented_symbols_are_reexported() -> None: + # TODO(jholveck): #493 Drop this compatibility-only re-export check after 10.x transition period. + expected = [ + "CFUNCTIONS", + "Display", + "PLAINMASK", + "XErrorEvent", + "XFixesCursorImage", + "XImage", + "XRRCrtcInfo", + "XRRModeInfo", + "XRRScreenResources", + "XWindowAttributes", + "ZPIXMAP", + ] + + for symbol in expected: + assert hasattr(mss.linux, symbol) diff --git a/src/tests/test_find_monitors.py b/src/tests/test_find_monitors.py index 7939e173..adf7a55b 100644 --- a/src/tests/test_find_monitors.py +++ b/src/tests/test_find_monitors.py @@ -1,18 +1,19 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ -import os -from mss import mss +from collections.abc import Callable +from mss import MSS -def test_get_monitors() -> None: - with mss(display=os.getenv("DISPLAY")) as sct: + +def test_get_monitors(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: assert sct.monitors -def test_keys_aio() -> None: - with mss(display=os.getenv("DISPLAY")) as sct: +def test_keys_aio(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: all_monitors = sct.monitors[0] assert "top" in all_monitors assert "left" in all_monitors @@ -20,8 +21,8 @@ def test_keys_aio() -> None: assert "width" in all_monitors -def test_keys_monitor_1() -> None: - with mss(display=os.getenv("DISPLAY")) as sct: +def test_keys_monitor_1(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: mon1 = sct.monitors[1] assert "top" in mon1 assert "left" in mon1 @@ -29,8 +30,8 @@ def test_keys_monitor_1() -> None: assert "width" in mon1 -def test_dimensions() -> None: - with mss(display=os.getenv("DISPLAY")) as sct: +def test_dimensions(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: mon = sct.monitors[1] assert mon["width"] > 0 assert mon["height"] > 0 diff --git a/src/tests/test_get_pixels.py b/src/tests/test_get_pixels.py index ec85f7f7..cc94aba7 100644 --- a/src/tests/test_get_pixels.py +++ b/src/tests/test_get_pixels.py @@ -3,16 +3,16 @@ """ import itertools -import os +from collections.abc import Callable import pytest -from mss import mss -from mss.base import ScreenShot + +from mss import MSS, ScreenShot from mss.exception import ScreenShotError -def test_grab_monitor() -> None: - with mss(display=os.getenv("DISPLAY")) as sct: +def test_grab_monitor(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: for mon in sct.monitors: image = sct.grab(mon) assert isinstance(image, ScreenShot) @@ -20,16 +20,16 @@ def test_grab_monitor() -> None: assert isinstance(image.rgb, bytes) -def test_grab_part_of_screen(pixel_ratio: int) -> None: - with mss(display=os.getenv("DISPLAY")) as sct: +def test_grab_part_of_screen(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: for width, height in itertools.product(range(1, 42), range(1, 42)): monitor = {"top": 160, "left": 160, "width": width, "height": height} image = sct.grab(monitor) assert image.top == 160 assert image.left == 160 - assert image.width == width * pixel_ratio - assert image.height == height * pixel_ratio + assert image.width == width + assert image.height == height def test_get_pixel(raw: bytes) -> None: diff --git a/src/tests/test_gnu_linux.py b/src/tests/test_gnu_linux.py index cca29abb..26861aae 100644 --- a/src/tests/test_gnu_linux.py +++ b/src/tests/test_gnu_linux.py @@ -1,16 +1,28 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ + +from __future__ import annotations + +import builtins +import ctypes.util import platform -from collections.abc import Generator -from unittest.mock import Mock, patch +from ctypes import CFUNCTYPE, POINTER, _Pointer, c_int +from typing import TYPE_CHECKING, Any +from unittest.mock import Mock, NonCallableMock, patch + +import pytest import mss import mss.linux -import pytest -from mss.base import MSSBase +import mss.linux.xcb +import mss.linux.xlib +from mss import MSS from mss.exception import ScreenShotError +if TYPE_CHECKING: + from collections.abc import Generator + pyvirtualdisplay = pytest.importorskip("pyvirtualdisplay") PYPY = platform.python_implementation() == "PyPy" @@ -20,143 +32,234 @@ DEPTH = 24 -@pytest.fixture() +def spy_and_patch(monkeypatch: pytest.MonkeyPatch, obj: Any, name: str) -> Mock: + """Replace obj.name with a call-through mock and return the mock.""" + real = getattr(obj, name) + spy = Mock(wraps=real) + monkeypatch.setattr(obj, name, spy, raising=False) + return spy + + +@pytest.fixture(autouse=True) +def without_libraries(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> Generator[None]: + marker = request.node.get_closest_marker("without_libraries") + if marker is None: + yield None + return + skip_find = frozenset(marker.args) + old_find_library = ctypes.util.find_library + + def new_find_library(name: str, *args: list, **kwargs: dict[str, Any]) -> str | None: + if name in skip_find: + return None + return old_find_library(name, *args, **kwargs) + + # We use a context here so other fixtures or the test itself can use .undo. + with monkeypatch.context() as mp: + mp.setattr(ctypes.util, "find_library", new_find_library) + yield None + + +@pytest.fixture def display() -> Generator: with pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH) as vdisplay: yield vdisplay.new_display_var +def test_default_backend(display: str) -> None: + with mss.MSS(display=display) as sct: + assert isinstance(sct, MSS) + + @pytest.mark.skipif(PYPY, reason="Failure on PyPy") -def test_factory_systems(monkeypatch: pytest.MonkeyPatch) -> None: +def test_factory_systems(monkeypatch: pytest.MonkeyPatch, backend: str) -> None: """Here, we are testing all systems. Too hard to maintain the test for all platforms, so test only on GNU/Linux. + + TODO: Revisit the non-Linux branches before the final PR. Confirm the + intended cross-platform behavior after the strategy refactor and, if + appropriate, narrow the accepted exception types again. """ # GNU/Linux monkeypatch.setattr(platform, "system", lambda: "LINUX") - with mss.mss() as sct: - assert isinstance(sct, MSSBase) + with mss.MSS(backend=backend) as sct: + assert isinstance(sct, MSS) monkeypatch.undo() # macOS monkeypatch.setattr(platform, "system", lambda: "Darwin") # ValueError on macOS Big Sur - with pytest.raises((ScreenShotError, ValueError)), mss.mss(): + with pytest.raises((ScreenShotError, ValueError)), mss.MSS(backend=backend): pass monkeypatch.undo() # Windows monkeypatch.setattr(platform, "system", lambda: "wInDoWs") - with pytest.raises(ImportError, match="cannot import name 'WINFUNCTYPE'"), mss.mss(): + with pytest.raises((ImportError, ScreenShotError)), mss.MSS(backend=backend): pass -def test_arg_display(display: str, monkeypatch: pytest.MonkeyPatch) -> None: +def test_arg_display(display: str, backend: str, monkeypatch: pytest.MonkeyPatch) -> None: # Good value - with mss.mss(display=display): + with mss.MSS(display=display, backend=backend): pass # Bad `display` (missing ":" in front of the number) - with pytest.raises(ScreenShotError), mss.mss(display="0"): + with pytest.raises(ScreenShotError), mss.MSS(display="0", backend=backend): pass # Invalid `display` that is not trivially distinguishable. - with pytest.raises(ScreenShotError), mss.mss(display=":INVALID"): + with pytest.raises(ScreenShotError), mss.MSS(display=":INVALID", backend=backend): pass # No `DISPLAY` in envars - monkeypatch.delenv("DISPLAY") - with pytest.raises(ScreenShotError), mss.mss(): + # The monkeypatch implementation of delenv seems to interact badly with some other uses of setenv, so we use a + # monkeypatch context to isolate it a bit. + with monkeypatch.context() as mp: + mp.delenv("DISPLAY") + with pytest.raises(ScreenShotError), mss.MSS(backend=backend): + pass + + +def test_xerror_without_details() -> None: + # Opening an invalid display with the Xlib backend will create an XError instance, but since there was no + # XErrorEvent, then the details won't be filled in. Generate one. + with pytest.raises(ScreenShotError) as excinfo, mss.MSS(display=":INVALID"): pass - -@pytest.mark.skipif(PYPY, reason="Failure on PyPy") -def test_bad_display_structure(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(mss.linux, "Display", lambda: None) - with pytest.raises(TypeError), mss.mss(): - pass + exc = excinfo.value + # Ensure it has no details. + assert not exc.details + # Ensure it can be stringified. + str(exc) -@patch("mss.linux._X11", new=None) -def test_no_xlib_library() -> None: - with pytest.raises(ScreenShotError), mss.mss(): +@pytest.mark.without_libraries("xcb") +@patch("mss.linux.xlib._X11", new=None) +def test_no_xlib_library(backend: str) -> None: + with pytest.raises(ScreenShotError), mss.MSS(backend=backend): pass -@patch("mss.linux._XRANDR", new=None) -def test_no_xrandr_extension() -> None: - with pytest.raises(ScreenShotError), mss.mss(): +@pytest.mark.without_libraries("xcb-randr") +@patch("mss.linux.xlib._XRANDR", new=None) +def test_no_xrandr_extension(backend: str) -> None: + with pytest.raises(ScreenShotError), mss.MSS(backend=backend): pass -@patch("mss.linux.MSS._is_extension_enabled", new=Mock(return_value=False)) +@patch("mss.linux.xlib.MSSImplXlib._is_extension_enabled", new=Mock(return_value=False)) def test_xrandr_extension_exists_but_is_not_enabled(display: str) -> None: - with pytest.raises(ScreenShotError), mss.mss(display=display): + with pytest.raises(ScreenShotError), mss.MSS(display=display, backend="xlib"): pass -def test_unsupported_depth() -> None: - with pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=8) as vdisplay: - with pytest.raises(ScreenShotError): - with mss.mss(display=vdisplay.new_display_var) as sct: - sct.grab(sct.monitors[1]) +def test_unsupported_depth(backend: str) -> None: + # 8-bit is normally PseudoColor. If the order of testing the display support changes, this might raise a + # different message; just change the match= accordingly. + with ( + pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=8) as vdisplay, + pytest.raises(ScreenShotError, match=r"\b8\b"), + mss.MSS(display=vdisplay.new_display_var, backend=backend) as sct, + ): + sct.grab(sct.monitors[1]) + # 16-bit is normally TrueColor, but still just 16 bits. + with ( + pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=16) as vdisplay, + pytest.raises(ScreenShotError, match=r"\b16\b"), + mss.MSS(display=vdisplay.new_display_var, backend=backend) as sct, + ): + sct.grab(sct.monitors[1]) -def test_region_out_of_monitor_bounds(display: str) -> None: - monitor = {"left": -30, "top": 0, "width": WIDTH, "height": HEIGHT} - assert not mss.linux._ERROR +def test__is_extension_enabled_unknown_name(display: str) -> None: + with mss.MSS(display=display, backend="xlib") as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + assert not sct._impl._is_extension_enabled("NOEXT") - with mss.mss(display=display) as sct: - with pytest.raises(ScreenShotError) as exc: - sct.grab(monitor) - assert str(exc.value) +def test_fast_function_for_monitor_details_retrieval(display: str, monkeypatch: pytest.MonkeyPatch) -> None: + with mss.MSS(display=display, backend="xlib") as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + assert hasattr(sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + fast_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + slow_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResources") + screenshot_with_fast_fn = sct.grab(sct.monitors[1]) - details = exc.value.details - assert details - assert isinstance(details, dict) - assert isinstance(details["error"], str) - assert not mss.linux._ERROR + fast_spy.assert_called() + slow_spy.assert_not_called() - assert not mss.linux._ERROR + assert set(screenshot_with_fast_fn.rgb) == {0} -def test__is_extension_enabled_unknown_name(display: str) -> None: - with mss.mss(display=display) as sct: - assert isinstance(sct, mss.linux.MSS) # For Mypy - assert not sct._is_extension_enabled("NOEXT") +def test_client_missing_fast_function_for_monitor_details_retrieval( + display: str, monkeypatch: pytest.MonkeyPatch +) -> None: + with mss.MSS(display=display, backend="xlib") as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + assert hasattr(sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + # Even though we're going to delete it, we'll still create a fast spy, to make sure that it isn't somehow + # getting accessed through a path we hadn't considered. + fast_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + slow_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResources") + # If we just delete sct._impl.xrandr.XRRGetScreenResourcesCurrent, it will get recreated automatically by ctypes + # the next time it's accessed. A Mock will remember that the attribute was explicitly deleted and hide it. + mock_xrandr = NonCallableMock(wraps=sct._impl.xrandr) + del mock_xrandr.XRRGetScreenResourcesCurrent + monkeypatch.setattr(sct._impl, "xrandr", mock_xrandr) + assert not hasattr(sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + screenshot_with_slow_fn = sct.grab(sct.monitors[1]) + fast_spy.assert_not_called() + slow_spy.assert_called() -def test_missing_fast_function_for_monitor_details_retrieval(display: str) -> None: - with mss.mss(display=display) as sct: - assert isinstance(sct, mss.linux.MSS) # For Mypy - assert hasattr(sct.xrandr, "XRRGetScreenResourcesCurrent") - screenshot_with_fast_fn = sct.grab(sct.monitors[1]) + assert set(screenshot_with_slow_fn.rgb) == {0} - assert set(screenshot_with_fast_fn.rgb) == {0} - with mss.mss(display=display) as sct: - assert isinstance(sct, mss.linux.MSS) # For Mypy - assert hasattr(sct.xrandr, "XRRGetScreenResourcesCurrent") - del sct.xrandr.XRRGetScreenResourcesCurrent +def test_server_missing_fast_function_for_monitor_details_retrieval( + display: str, monkeypatch: pytest.MonkeyPatch +) -> None: + fake_xrrqueryversion_type = CFUNCTYPE( + c_int, # Status + POINTER(mss.linux.xlib.Display), # Display* + POINTER(c_int), # int* major + POINTER(c_int), # int* minor + ) + + @fake_xrrqueryversion_type + def fake_xrrqueryversion(_dpy: _Pointer, major_p: _Pointer, minor_p: _Pointer) -> int: + major_p[0] = 1 + minor_p[0] = 2 + return 1 + + with mss.MSS(display=display, backend="xlib") as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + monkeypatch.setattr(sct._impl.xrandr, "XRRQueryVersion", fake_xrrqueryversion) + fast_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResourcesCurrent") + slow_spy = spy_and_patch(monkeypatch, sct._impl.xrandr, "XRRGetScreenResources") screenshot_with_slow_fn = sct.grab(sct.monitors[1]) + fast_spy.assert_not_called() + slow_spy.assert_called() + assert set(screenshot_with_slow_fn.rgb) == {0} -def test_with_cursor(display: str) -> None: - with mss.mss(display=display) as sct: - assert not hasattr(sct, "xfixes") +def test_with_cursor(display: str, backend: str) -> None: + with mss.MSS(display=display, backend=backend) as sct: + assert not hasattr(sct._impl, "xfixes") assert not sct.with_cursor screenshot_without_cursor = sct.grab(sct.monitors[1]) # 1 color: black assert set(screenshot_without_cursor.rgb) == {0} - with mss.mss(display=display, with_cursor=True) as sct: - assert hasattr(sct, "xfixes") + with mss.MSS(display=display, backend=backend, with_cursor=True) as sct: + if backend == "xlib": + assert hasattr(sct._impl, "xfixes") assert sct.with_cursor screenshot_with_cursor = sct.grab(sct.monitors[1]) @@ -164,16 +267,86 @@ def test_with_cursor(display: str) -> None: assert set(screenshot_with_cursor.rgb) == {0, 255} -@patch("mss.linux._XFIXES", new=None) +@patch("mss.linux.xlib._XFIXES", new=None) def test_with_cursor_but_not_xfixes_extension_found(display: str) -> None: - with mss.mss(display=display, with_cursor=True) as sct: - assert not hasattr(sct, "xfixes") + with mss.MSS(display=display, backend="xlib", with_cursor=True) as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + assert not hasattr(sct._impl, "xfixes") assert not sct.with_cursor def test_with_cursor_failure(display: str) -> None: - with mss.mss(display=display, with_cursor=True) as sct: - assert isinstance(sct, mss.linux.MSS) # For Mypy - with patch.object(sct.xfixes, "XFixesGetCursorImage", return_value=None): - with pytest.raises(ScreenShotError): - sct.grab(sct.monitors[1]) + with mss.MSS(display=display, backend="xlib", with_cursor=True) as sct: + assert isinstance(sct._impl, mss.linux.xlib.MSSImplXlib) # For Mypy + with ( + patch.object(sct._impl.xfixes, "XFixesGetCursorImage", return_value=None), + pytest.raises(ScreenShotError), + ): + sct.grab(sct.monitors[1]) + + +def test_shm_available() -> None: + """Verify that the xshmgetimage backend doesn't always fallback. + + Since this backend does an automatic fallback for certain types of + anticipated issues, that could cause some failures to be masked. + Ensure this isn't happening. + """ + with ( + pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH) as vdisplay, + mss.MSS(display=vdisplay.new_display_var, backend="xshmgetimage") as sct, + ): + assert isinstance(sct._impl, mss.linux.xshmgetimage.MSSImplXShmGetImage) # For Mypy + # The status currently isn't established as final until a grab succeeds. + sct.grab(sct.monitors[0]) + assert sct._impl.shm_status == mss.linux.xshmgetimage.ShmStatus.AVAILABLE + + +def test_shm_fallback() -> None: + """Verify that the xshmgetimage backend falls back if MIT-SHM fails. + + The most common case when a fallback is needed is with a TCP + connection, such as the one used with ssh relaying. By using + DISPLAY=localhost:99 instead of DISPLAY=:99, we connect over TCP + instead of a local-domain socket. This is sufficient to prevent + MIT-SHM from completing its setup: the extension is available, but + won't be able to attach a shared memory segment. + """ + with ( + pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH, extra_args=["-listen", "tcp"]) as vdisplay, + mss.MSS(display=f"localhost{vdisplay.new_display_var}", backend="xshmgetimage") as sct, + ): + assert isinstance(sct._impl, mss.linux.xshmgetimage.MSSImplXShmGetImage) # For Mypy + # Ensure that the grab call completes without exception. + sct.grab(sct.monitors[0]) + # Ensure that it really did have to fall back; otherwise, we'd need to change how we test this case. + assert sct._impl.shm_status == mss.linux.xshmgetimage.ShmStatus.UNAVAILABLE + + +def test_exception_while_holding_memoryview(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify that an exception at a particular point doesn't prevent cleanup. + + The particular point is the window when the XShmGetImage's mmapped + buffer has a memoryview still outstanding, and the pixel data is + being copied into a bytearray. This can take a few milliseconds. + """ + # Force an exception during bytearray(img_mv) + real_bytearray = builtins.bytearray + + def boom(*args: list, **kwargs: dict[str, Any]) -> bytearray: + # Only explode when called with the memoryview (the code path we care about). + if len(args) > 0 and isinstance(args[0], memoryview): + # We still need to eliminate args from the stack frame, just like the fix. + del args, kwargs + msg = "Boom!" + raise RuntimeError(msg) + return real_bytearray(*args, **kwargs) + + # We have to be careful about the order in which we catch things. If we were to catch and discard the exception + # before the MSS object closes, it won't trigger the bug. That's why we have the pytest.raises outside the + # mss.MSS block. In addition, we do as much as we can before patching bytearray, to limit its scope. + with pytest.raises(RuntimeError, match="Boom!"), mss.MSS(backend="xshmgetimage") as sct: # noqa: PT012 + monitor = sct.monitors[0] + with monkeypatch.context() as m: + m.setattr(builtins, "bytearray", boom) + sct.grab(monitor) diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py index 2ec96ac1..62d3840d 100644 --- a/src/tests/test_implementation.py +++ b/src/tests/test_implementation.py @@ -1,26 +1,31 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ + from __future__ import annotations -import os -import os.path import platform import sys +import threading +import time from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import Mock, patch -import mss -import mss.tools import pytest + +import mss from mss.__main__ import main as entry_point -from mss.base import MSSBase +from mss.base import MSS, MSSImplementation from mss.exception import ScreenShotError from mss.screenshot import ScreenShot if TYPE_CHECKING: - from mss.models import Monitor + from collections.abc import Callable + from typing import Any + + from mss.models import Monitor, Monitors try: from datetime import UTC @@ -31,134 +36,166 @@ UTC = timezone.utc -class MSS0(MSSBase): +class MSS0(MSSImplementation): """Nothing implemented.""" -class MSS1(MSSBase): +class MSS1(MSSImplementation): """Only `grab()` implemented.""" def grab(self, monitor: Monitor) -> None: # type: ignore[override] pass -class MSS2(MSSBase): +class MSS2(MSSImplementation): """Only `monitor` implemented.""" - @property - def monitors(self) -> list: + def monitors(self) -> Monitors: return [] @pytest.mark.parametrize("cls", [MSS0, MSS1, MSS2]) -def test_incomplete_class(cls: type[MSSBase]) -> None: +def test_incomplete_class(cls: type[MSSImplementation]) -> None: with pytest.raises(TypeError): cls() -def test_bad_monitor() -> None: - with mss.mss(display=os.getenv("DISPLAY")) as sct, pytest.raises(ScreenShotError): +def test_bad_monitor(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct, pytest.raises(ScreenShotError): sct.shot(mon=222) -def test_repr(pixel_ratio: int) -> None: +def test_repr(mss_impl: Callable[..., MSS]) -> None: box = {"top": 0, "left": 0, "width": 10, "height": 10} - expected_box = { - "top": 0, - "left": 0, - "width": 10 * pixel_ratio, - "height": 10 * pixel_ratio, - } - with mss.mss(display=os.getenv("DISPLAY")) as sct: + expected_box = {"top": 0, "left": 0, "width": 10, "height": 10} + with mss_impl() as sct: img = sct.grab(box) ref = ScreenShot(bytearray(b"42"), expected_box) assert repr(img) == repr(ref) -def test_factory(monkeypatch: pytest.MonkeyPatch) -> None: - # Current system - with mss.mss() as sct: - assert isinstance(sct, MSSBase) +def test_factory_no_backend() -> None: + with mss.MSS() as sct: + assert isinstance(sct, MSS) - # Unknown + +def test_factory_current_system(backend: str) -> None: + with mss.MSS(backend=backend) as sct: + assert isinstance(sct, MSS) + + +def test_factory_unknown_system(backend: str, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(platform, "system", lambda: "Chuck Norris") with pytest.raises(ScreenShotError) as exc: - mss.mss() + mss.MSS(backend=backend) monkeypatch.undo() error = exc.value.args[0] assert error == "System 'chuck norris' not (yet?) implemented." -@patch.object(sys, "argv", new=[]) # Prevent side effects while testing +@pytest.fixture +def reset_sys_argv(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "argv", []) + + +@pytest.mark.usefixtures("reset_sys_argv") @pytest.mark.parametrize("with_cursor", [False, True]) -def test_entry_point(with_cursor: bool, capsys: pytest.CaptureFixture) -> None: - def main(*args: str, ret: int = 0) -> None: +class TestEntryPoint: + """CLI entry-point scenarios split into focused tests.""" + + @staticmethod + def _run_main(with_cursor: bool, *args: str, ret: int = 0) -> None: if with_cursor: args = (*args, "--with-cursor") assert entry_point(*args) == ret - # No arguments - 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) - - 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") - - 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") - - fmt = "sct-{mon}-{width}x{height}.png" - for opt in ("-o", "--out"): - main(opt, fmt) - 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) - - 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)) - captured = capsys.readouterr() - assert captured.out.endswith(filename + "\n") - assert os.path.isfile(filename) - os.remove(filename) - - coordinates = "2,12,40,67" - filename = "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) - - coordinates = "2,12,40" - for opt in ("-c", "--coordinates"): - main(opt, coordinates, ret=2) + def test_no_arguments(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + self._run_main(with_cursor) captured = capsys.readouterr() - assert captured.out == "Coordinates syntax: top, left, width, height\n" + for mon, line in enumerate(captured.out.splitlines(), 1): + filename = Path(f"monitor-{mon}.png") + assert line.endswith(filename.name) + assert filename.is_file() + filename.unlink() + + def test_monitor_option_and_quiet(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + file = Path("monitor-1.png") + filename: Path | None = None + for opt in ("-m", "--monitor"): + self._run_main(with_cursor, opt, "1") + captured = capsys.readouterr() + assert captured.out.endswith(f"{file.name}\n") + filename = Path(captured.out.rstrip()) + assert filename.is_file() + filename.unlink() + + assert filename is not None + for opts in zip(["-m 1", "--monitor=1"], ["-q", "--quiet"]): + self._run_main(with_cursor, *opts) + captured = capsys.readouterr() + assert not captured.out + assert filename.is_file() + filename.unlink() + + def test_custom_output_pattern(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + fmt = "sct-{mon}-{width}x{height}.png" + for opt in ("-o", "--out"): + self._run_main(with_cursor, opt, fmt) + captured = capsys.readouterr() + with mss.MSS() as sct: + for mon, (monitor, line) in enumerate(zip(sct.monitors[1:], captured.out.splitlines()), 1): + filename = Path(fmt.format(mon=mon, **monitor)) + assert line.endswith(filename.name) + assert filename.is_file() + filename.unlink() + + def test_output_pattern_with_date(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + fmt = "sct_{mon}-{date:%Y-%m-%d}.png" + for opt in ("-o", "--out"): + self._run_main(with_cursor, "-m 1", opt, fmt) + filename = Path(fmt.format(mon=1, date=datetime.now(tz=UTC))) + captured = capsys.readouterr() + assert captured.out.endswith(f"{filename}\n") + assert filename.is_file() + filename.unlink() + + def test_coordinates_capture(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + coordinates = "2,12,40,67" + filename = Path("sct-2x12_40x67.png") + for opt in ("-c", "--coordinates"): + self._run_main(with_cursor, opt, coordinates) + captured = capsys.readouterr() + assert captured.out.endswith(f"{filename}\n") + assert filename.is_file() + filename.unlink() + + def test_invalid_coordinates(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + coordinates = "2,12,40" + 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" + + def test_backend_option(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + backend = "default" + for opt in ("-b", "--backend"): + self._run_main(with_cursor, opt, backend, "-m1") + captured = capsys.readouterr() + filename = Path(captured.out.rstrip()) + assert filename.is_file() + filename.unlink() + + def test_invalid_backend_option(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + backend = "chuck_norris" + for opt in ("-b", "--backend"): + self._run_main(with_cursor, opt, backend, "-m1", ret=2) + captured = capsys.readouterr() + assert "argument -b/--backend: invalid choice: 'chuck_norris' (choose from" in captured.err @patch.object(sys, "argv", new=[]) # Prevent side effects while testing -@patch("mss.base.MSSBase.monitors", new=[]) +@patch("mss.base.MSS.monitors", new=[]) @pytest.mark.parametrize("quiet", [False, True]) def test_entry_point_error(quiet: bool, capsys: pytest.CaptureFixture) -> None: def main(*args: str) -> int: @@ -178,18 +215,20 @@ def main(*args: str) -> int: def test_entry_point_with_no_argument(capsys: pytest.CaptureFixture) -> None: # Make sure to fail if arguments are not handled - with patch("mss.factory.mss", new=Mock(side_effect=RuntimeError("Boom!"))): - with patch.object(sys, "argv", ["mss", "--help"]): - with pytest.raises(SystemExit) as exc: - entry_point() - assert exc.value.code == 0 + with ( + patch("mss.factory.mss", new=Mock(side_effect=RuntimeError("Boom!"))), + patch.object(sys, "argv", ["mss", "--help"]), + pytest.raises(SystemExit) as exc, + ): + entry_point() + assert exc.value.code == 0 captured = capsys.readouterr() assert not captured.err assert "usage: mss" in captured.out -def test_grab_with_tuple(pixel_ratio: int) -> None: +def test_grab_with_tuple(mss_impl: Callable[..., MSS]) -> None: left = 100 top = 100 right = 500 @@ -197,11 +236,11 @@ def test_grab_with_tuple(pixel_ratio: int) -> None: width = right - left # 400px width height = lower - top # 400px height - with mss.mss(display=os.getenv("DISPLAY")) as sct: + with mss_impl() as sct: # PIL like box = (left, top, right, lower) im = sct.grab(box) - assert im.size == (width * pixel_ratio, height * pixel_ratio) + assert im.size == (width, height) # MSS like box2 = {"left": left, "top": top, "width": width, "height": height} @@ -211,8 +250,25 @@ def test_grab_with_tuple(pixel_ratio: int) -> None: assert im.rgb == im2.rgb -def test_grab_with_tuple_percents(pixel_ratio: int) -> None: - with mss.mss(display=os.getenv("DISPLAY")) as sct: +def test_grab_with_invalid_tuple(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: + # Remember that rect tuples are PIL-style: (left, top, right, bottom) + # Negative left/top coordinates are valid for multi-monitor setups + # where monitors can be positioned to the left of or above the primary. + + # Negative width (right < left) + negative_box = (100, 100, 50, 500) + with pytest.raises(ScreenShotError): + sct.grab(negative_box) + + # Negative height (bottom < top) + negative_box = (100, 100, 500, 50) + with pytest.raises(ScreenShotError): + sct.grab(negative_box) + + +def test_grab_with_tuple_percents(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: monitor = sct.monitors[1] left = monitor["left"] + monitor["width"] * 5 // 100 # 5% from the left top = monitor["top"] + monitor["height"] * 5 // 100 # 5% from the top @@ -224,7 +280,7 @@ def test_grab_with_tuple_percents(pixel_ratio: int) -> None: # PIL like box = (left, top, right, lower) im = sct.grab(box) - assert im.size == (width * pixel_ratio, height * pixel_ratio) + assert im.size == (width, height) # MSS like box2 = {"left": left, "top": top, "width": width, "height": height} @@ -234,29 +290,45 @@ def test_grab_with_tuple_percents(pixel_ratio: int) -> None: assert im.rgb == im2.rgb -def test_thread_safety() -> None: - """Regression test for issue #169.""" - import threading - import time +class TestThreadSafety: + def run_test(self, do_grab: Callable[[], Any]) -> None: + def record() -> None: + """Record for one second.""" + start_time = time.time() + while time.time() - start_time < 1: + do_grab() - def record(check: dict) -> None: - """Record for one second.""" - start_time = time.time() - while time.time() - start_time < 1: - with mss.mss() as sct: - sct.grab(sct.monitors[1]) + checkpoint[threading.current_thread()] = True + + checkpoint: dict = {} + t1 = threading.Thread(target=record) + t2 = threading.Thread(target=record) - check[threading.current_thread()] = True + t1.start() + time.sleep(0.5) + t2.start() - checkpoint: dict = {} - t1 = threading.Thread(target=record, args=(checkpoint,)) - t2 = threading.Thread(target=record, args=(checkpoint,)) + t1.join() + t2.join() + + assert len(checkpoint) == 2 + + def test_issue_169(self, backend: str) -> None: + """Regression test for issue #169.""" + + def do_grab() -> None: + with mss.MSS(backend=backend) as sct: + sct.grab(sct.monitors[1]) - t1.start() - time.sleep(0.5) - t2.start() + self.run_test(do_grab) - t1.join() - t2.join() + def test_same_object_multiple_threads(self, backend: str) -> None: + """Ensure that the same MSS object can be used by multiple threads. - assert len(checkpoint) == 2 + This also implicitly tests that it can be used on a thread + different than the one that created it. + """ + if backend == "xlib": + pytest.skip("The xlib backend does not support this ability") + with mss.MSS(backend=backend) as sct: + self.run_test(lambda: sct.grab(sct.monitors[1])) diff --git a/src/tests/test_issue_220.py b/src/tests/test_issue_220.py index 203147ef..39369477 100644 --- a/src/tests/test_issue_220.py +++ b/src/tests/test_issue_220.py @@ -1,13 +1,17 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ -import mss + +from functools import partial + import pytest +import mss + tkinter = pytest.importorskip("tkinter") -@pytest.fixture() +@pytest.fixture def root() -> tkinter.Tk: # type: ignore[name-defined] try: master = tkinter.Tk() @@ -20,16 +24,18 @@ def root() -> tkinter.Tk: # type: ignore[name-defined] master.destroy() -def take_screenshot() -> None: +def take_screenshot(*, backend: str) -> None: region = {"top": 370, "left": 1090, "width": 80, "height": 390} - with mss.mss() as sct: + with mss.MSS(backend=backend) as sct: sct.grab(region) -def create_top_level_win(master: tkinter.Tk) -> None: # type: ignore[name-defined] +def create_top_level_win(master: tkinter.Tk, backend: str) -> None: # type: ignore[name-defined] top_level_win = tkinter.Toplevel(master) - take_screenshot_btn = tkinter.Button(top_level_win, text="Take screenshot", command=take_screenshot) + take_screenshot_btn = tkinter.Button( + top_level_win, text="Take screenshot", command=partial(take_screenshot, backend=backend) + ) take_screenshot_btn.pack() take_screenshot_btn.invoke() @@ -41,8 +47,8 @@ def create_top_level_win(master: tkinter.Tk) -> None: # type: ignore[name-defin master.update() -def test_regression(root: tkinter.Tk, capsys: pytest.CaptureFixture) -> None: # type: ignore[name-defined] - btn = tkinter.Button(root, text="Open TopLevel", command=lambda: create_top_level_win(root)) +def test_regression(root: tkinter.Tk, capsys: pytest.CaptureFixture, backend: str) -> None: # type: ignore[name-defined] + btn = tkinter.Button(root, text="Open TopLevel", command=lambda: create_top_level_win(root, backend)) btn.pack() # First screenshot: it works diff --git a/src/tests/test_leaks.py b/src/tests/test_leaks.py index 7c0fd0f7..a17f8e9c 100644 --- a/src/tests/test_leaks.py +++ b/src/tests/test_leaks.py @@ -1,13 +1,17 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ + +import ctypes import os import platform -from typing import Callable +import subprocess +from collections.abc import Callable -import mss import pytest +import mss + OS = platform.system().lower() PID = os.getpid() @@ -16,67 +20,63 @@ def get_opened_socket() -> int: """GNU/Linux: a way to get the opened sockets count. It will be used to check X server connections are well closed. """ - import subprocess - - cmd = f"lsof -U | grep {PID}" - output = subprocess.check_output(cmd, shell=True) - return len(output.splitlines()) + output = subprocess.check_output(["lsof", "-a", "-U", "-Ff", f"-p{PID}"]) + # The first line will be "p{PID}". The remaining lines start with "f", one per open socket. + return len([line for line in output.splitlines() if line.startswith(b"f")]) def get_handles() -> int: """Windows: a way to get the GDI handles count. It will be used to check the handles count is not growing, showing resource leaks. """ - import ctypes - PROCESS_QUERY_INFORMATION = 0x400 # noqa:N806 GR_GDIOBJECTS = 0 # noqa:N806 h = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, 0, PID) return ctypes.windll.user32.GetGuiResources(h, GR_GDIOBJECTS) -@pytest.fixture() +@pytest.fixture def monitor_func() -> Callable[[], int]: """OS specific function to check resources in use.""" return get_opened_socket if OS == "linux" else get_handles -def bound_instance_without_cm() -> None: +def bound_instance_without_cm(*, backend: str) -> None: # Will always leak - sct = mss.mss() + sct = mss.MSS(backend=backend) sct.shot() -def bound_instance_without_cm_but_use_close() -> None: - sct = mss.mss() +def bound_instance_without_cm_but_use_close(*, backend: str) -> None: + sct = mss.MSS(backend=backend) sct.shot() sct.close() # Calling .close() twice should be possible sct.close() -def unbound_instance_without_cm() -> None: +def unbound_instance_without_cm(*, backend: str) -> None: # Will always leak - mss.mss().shot() + mss.MSS(backend=backend).shot() -def with_context_manager() -> None: - with mss.mss() as sct: +def with_context_manager(*, backend: str) -> None: + with mss.MSS(backend=backend) as sct: sct.shot() -def regression_issue_128() -> None: +def regression_issue_128(*, backend: str) -> None: """Regression test for issue #128: areas overlap.""" - with mss.mss() as sct: + with mss.MSS(backend=backend) as sct: area1 = {"top": 50, "left": 7, "width": 400, "height": 320, "mon": 1} sct.grab(area1) area2 = {"top": 200, "left": 200, "width": 320, "height": 320, "mon": 1} sct.grab(area2) -def regression_issue_135() -> None: +def regression_issue_135(*, backend: str) -> None: """Regression test for issue #135: multiple areas.""" - with mss.mss() as sct: + with mss.MSS(backend=backend) as sct: bounding_box_notes = {"top": 0, "left": 0, "width": 100, "height": 100} sct.grab(bounding_box_notes) bounding_box_test = {"top": 220, "left": 220, "width": 100, "height": 100} @@ -85,14 +85,14 @@ def regression_issue_135() -> None: sct.grab(bounding_box_score) -def regression_issue_210() -> None: +def regression_issue_210(*, backend: str) -> None: """Regression test for issue #210: multiple X servers.""" pyvirtualdisplay = pytest.importorskip("pyvirtualdisplay") - with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.mss(): + with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.MSS(backend=backend): pass - with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.mss(): + with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.MSS(backend=backend): pass @@ -109,17 +109,17 @@ def regression_issue_210() -> None: regression_issue_210, ], ) -def test_resource_leaks(func: Callable[[], None], monitor_func: Callable[[], int]) -> None: +def test_resource_leaks(func: Callable[..., None], monitor_func: Callable[[], int], backend: str) -> None: """Check for resource leaks with different use cases.""" # Warm-up - func() + func(backend=backend) original_resources = monitor_func() allocated_resources = 0 for _ in range(5): - func() + func(backend=backend) new_resources = monitor_func() allocated_resources = max(allocated_resources, new_resources) - assert original_resources == allocated_resources + assert allocated_resources <= original_resources diff --git a/src/tests/test_macos.py b/src/tests/test_macos.py index 9346581f..e343d2d5 100644 --- a/src/tests/test_macos.py +++ b/src/tests/test_macos.py @@ -1,11 +1,14 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ + import ctypes.util import platform +from unittest.mock import patch -import mss import pytest + +import mss from mss.exception import ScreenShotError if platform.system().lower() != "darwin": @@ -46,22 +49,36 @@ def test_implementation(monkeypatch: pytest.MonkeyPatch) -> None: if version < 10.16: monkeypatch.setattr(ctypes.util, "find_library", lambda _: None) with pytest.raises(ScreenShotError): - mss.mss() + mss.MSS() monkeypatch.undo() - with mss.mss() as sct: - assert isinstance(sct, mss.darwin.MSS) # For Mypy + with mss.MSS() as sct: + assert isinstance(sct._impl, mss.darwin.MSSImplDarwin) # For Mypy # Test monitor's rotation original = sct.monitors[1] - monkeypatch.setattr(sct.core, "CGDisplayRotation", lambda _: -90.0) - sct._monitors = [] + monkeypatch.setattr(sct._impl.core, "CGDisplayRotation", lambda _: -90.0) + sct._monitors = None modified = sct.monitors[1] assert original["width"] == modified["height"] assert original["height"] == modified["width"] monkeypatch.undo() # Test bad data retrieval - monkeypatch.setattr(sct.core, "CGWindowListCreateImage", lambda *_: None) + monkeypatch.setattr(sct._impl.core, "CGWindowListCreateImage", lambda *_: None) with pytest.raises(ScreenShotError): sct.grab(sct.monitors[1]) + + +def test_scaling_on() -> None: + """Screnshots are taken at the nominal resolution by default, but scaling can be turned on manually.""" + # Grab a 1x1 screenshot + region = {"top": 0, "left": 0, "width": 1, "height": 1} + + with mss.MSS() as sct: + # Nominal resolution, i.e.: scaling is off + assert sct.grab(region).size[0] == 1 + + # Retina resolution, i.e.: scaling is on + with patch.object(mss.darwin, "IMAGE_OPTIONS", 0): + assert sct.grab(region).size[0] in {1, 2} # 1 on the CI, 2 for all other the world diff --git a/src/tests/test_primary_monitor.py b/src/tests/test_primary_monitor.py new file mode 100644 index 00000000..160abc82 --- /dev/null +++ b/src/tests/test_primary_monitor.py @@ -0,0 +1,46 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import platform +from collections.abc import Callable + +import pytest + +from mss import MSS + + +def test_primary_monitor(mss_impl: Callable[..., MSS]) -> None: + """Test that primary_monitor property works correctly.""" + with mss_impl() as sct: + primary = sct.primary_monitor + monitors = sct.monitors + + # Should return a valid monitor dict + assert isinstance(primary, dict) + assert "left" in primary + assert "top" in primary + assert "width" in primary + assert "height" in primary + + # Should be in the monitors list (excluding index 0 which is "all monitors") + assert primary in monitors[1:] + + # Should either be marked as primary or be the first monitor as fallback + if primary.get("is_primary", False): + assert primary["is_primary"] is True + else: + assert primary == monitors[1] + + +@pytest.mark.skipif(platform.system() != "Windows", reason="Windows only") +def test_primary_monitor_coordinates_windows() -> None: + """Test that on Windows, the primary monitor has coordinates at (0, 0).""" + import mss # noqa: PLC0415 + + with mss.MSS() as sct: + primary = sct.primary_monitor + if primary.get("is_primary", False): + # On Windows, the primary monitor is at (0, 0) + assert primary["left"] == 0 + assert primary["top"] == 0 diff --git a/src/tests/test_save.py b/src/tests/test_save.py index a46a4fb2..275c4d11 100644 --- a/src/tests/test_save.py +++ b/src/tests/test_save.py @@ -1,11 +1,14 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ -import os.path + +from collections.abc import Callable from datetime import datetime +from pathlib import Path import pytest -from mss import mss + +from mss import MSS try: from datetime import UTC @@ -16,65 +19,65 @@ UTC = timezone.utc -def test_at_least_2_monitors() -> None: - with mss(display=os.getenv("DISPLAY")) as sct: +def test_at_least_2_monitors(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() as sct: assert list(sct.save(mon=0)) -def test_files_exist() -> None: - with mss(display=os.getenv("DISPLAY")) as sct: +def test_files_exist(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() 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 test_callback(mss_impl: Callable[..., MSS]) -> 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: + with mss_impl() 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: +def test_output_format_simple(mss_impl: Callable[..., MSS]) -> None: + with mss_impl() 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: +def test_output_format_positions_and_sizes(mss_impl: Callable[..., MSS]) -> None: fmt = "sct-{top}x{left}_{width}x{height}.png" - with mss(display=os.getenv("DISPLAY")) as sct: + with mss_impl() 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: +def test_output_format_date_simple(mss_impl: Callable[..., MSS]) -> None: fmt = "sct_{mon}-{date}.png" - with mss(display=os.getenv("DISPLAY")) as sct: + with mss_impl() 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.") -def test_output_format_date_custom() -> None: +def test_output_format_date_custom(mss_impl: Callable[..., MSS]) -> None: fmt = "sct_{date:%Y-%m-%d}.png" - with mss(display=os.getenv("DISPLAY")) as sct: + with mss_impl() 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 1bd5a667..5b63bdde 100644 --- a/src/tests/test_setup.py +++ b/src/tests/test_setup.py @@ -1,12 +1,15 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ + import platform +import sys import tarfile from subprocess import STDOUT, check_call, check_output from zipfile import ZipFile import pytest + from mss import __version__ if platform.system().lower() != "linux": @@ -15,9 +18,9 @@ pytest.importorskip("build") pytest.importorskip("twine") -SDIST = "python -m build --sdist".split() -WHEEL = "python -m build --wheel".split() -CHECK = "twine check --strict".split() +SDIST = [sys.executable, "-m", "build", "--sdist"] +WHEEL = [sys.executable, "-m", "build", "--wheel"] +CHECK = [sys.executable, "-m", "twine", "check", "--strict"] def test_sdist() -> None: @@ -40,6 +43,7 @@ def test_sdist() -> None: f"mss-{__version__}/PKG-INFO", f"mss-{__version__}/README.md", f"mss-{__version__}/docs/source/api.rst", + f"mss-{__version__}/docs/source/changelog.rst", f"mss-{__version__}/docs/source/conf.py", f"mss-{__version__}/docs/source/developers.rst", f"mss-{__version__}/docs/source/examples.rst", @@ -49,6 +53,7 @@ def test_sdist() -> None: f"mss-{__version__}/docs/source/examples/fps_multiprocessing.py", f"mss-{__version__}/docs/source/examples/from_pil_tuple.py", f"mss-{__version__}/docs/source/examples/linux_display_keyword.py", + f"mss-{__version__}/docs/source/examples/linux_xshm_backend.py", f"mss-{__version__}/docs/source/examples/opencv_numpy.py", f"mss-{__version__}/docs/source/examples/part_of_screen.py", f"mss-{__version__}/docs/source/examples/part_of_screen_monitor_2.py", @@ -58,6 +63,7 @@ def test_sdist() -> None: f"mss-{__version__}/docs/source/installation.rst", f"mss-{__version__}/docs/source/support.rst", f"mss-{__version__}/docs/source/usage.rst", + f"mss-{__version__}/docs/source/versioning.rst", f"mss-{__version__}/docs/source/where.rst", f"mss-{__version__}/pyproject.toml", f"mss-{__version__}/src/mss/__init__.py", @@ -66,18 +72,30 @@ def test_sdist() -> None: f"mss-{__version__}/src/mss/darwin.py", f"mss-{__version__}/src/mss/exception.py", f"mss-{__version__}/src/mss/factory.py", - f"mss-{__version__}/src/mss/linux.py", + f"mss-{__version__}/src/mss/linux/__init__.py", + f"mss-{__version__}/src/mss/linux/base.py", + f"mss-{__version__}/src/mss/linux/xcb.py", + f"mss-{__version__}/src/mss/linux/xcbgen.py", + f"mss-{__version__}/src/mss/linux/xcbhelpers.py", + f"mss-{__version__}/src/mss/linux/xgetimage.py", + f"mss-{__version__}/src/mss/linux/xlib.py", + f"mss-{__version__}/src/mss/linux/xshmgetimage.py", f"mss-{__version__}/src/mss/models.py", f"mss-{__version__}/src/mss/py.typed", 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/bench_grab_windows.py", f"mss-{__version__}/src/tests/conftest.py", f"mss-{__version__}/src/tests/res/monitor-1024x768.raw.zip", f"mss-{__version__}/src/tests/test_bgra_to_rgb.py", f"mss-{__version__}/src/tests/test_cls_image.py", + f"mss-{__version__}/src/tests/test_compat_10_1.py", + f"mss-{__version__}/src/tests/test_compat_exports.py", + f"mss-{__version__}/src/tests/test_compat_linux_api.py", f"mss-{__version__}/src/tests/test_find_monitors.py", f"mss-{__version__}/src/tests/test_get_pixels.py", f"mss-{__version__}/src/tests/test_gnu_linux.py", @@ -85,11 +103,22 @@ def test_sdist() -> None: f"mss-{__version__}/src/tests/test_issue_220.py", f"mss-{__version__}/src/tests/test_leaks.py", f"mss-{__version__}/src/tests/test_macos.py", + f"mss-{__version__}/src/tests/test_primary_monitor.py", f"mss-{__version__}/src/tests/test_save.py", f"mss-{__version__}/src/tests/test_setup.py", - f"mss-{__version__}/src/tests/test_third_party.py", f"mss-{__version__}/src/tests/test_tools.py", f"mss-{__version__}/src/tests/test_windows.py", + f"mss-{__version__}/src/tests/test_xcb.py", + f"mss-{__version__}/src/tests/third_party/__init__.py", + f"mss-{__version__}/src/tests/third_party/test_numpy.py", + f"mss-{__version__}/src/tests/third_party/test_pil.py", + f"mss-{__version__}/src/xcbproto/README.md", + f"mss-{__version__}/src/xcbproto/gen_xcb_to_py.py", + f"mss-{__version__}/src/xcbproto/randr.xml", + f"mss-{__version__}/src/xcbproto/render.xml", + f"mss-{__version__}/src/xcbproto/shm.xml", + f"mss-{__version__}/src/xcbproto/xfixes.xml", + f"mss-{__version__}/src/xcbproto/xproto.xml", ] @@ -116,7 +145,14 @@ def test_wheel() -> None: "mss/darwin.py", "mss/exception.py", "mss/factory.py", - "mss/linux.py", + "mss/linux/__init__.py", + "mss/linux/base.py", + "mss/linux/xcb.py", + "mss/linux/xcbgen.py", + "mss/linux/xcbhelpers.py", + "mss/linux/xgetimage.py", + "mss/linux/xlib.py", + "mss/linux/xshmgetimage.py", "mss/models.py", "mss/py.typed", "mss/screenshot.py", diff --git a/src/tests/test_tools.py b/src/tests/test_tools.py index 618f682e..f4575666 100644 --- a/src/tests/test_tools.py +++ b/src/tests/test_tools.py @@ -1,70 +1,173 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ -import hashlib -import os.path -import zlib + +from __future__ import annotations + +import io +import struct +from pathlib import Path +from typing import TYPE_CHECKING import pytest -from mss import mss -from mss.tools import to_png + +from mss.tools import parse_edid, to_png + +if TYPE_CHECKING: + from collections.abc import Callable + + from mss import MSS WIDTH = 10 HEIGHT = 10 -MD5SUM = "055e615b74167c9bdfea16a00539450c" -def test_bad_compression_level() -> None: - with mss(compression_level=42, display=os.getenv("DISPLAY")) as sct, pytest.raises(zlib.error): +def assert_is_valid_png(*, raw: bytes | None = None, file: Path | None = None) -> None: + Image = pytest.importorskip("PIL.Image", reason="PIL module not available.") # noqa: N806 + + assert bool(Image.open(io.BytesIO(raw) if raw is not None else file).tobytes()) + try: + Image.open(io.BytesIO(raw) if raw is not None else file).verify() + except Exception: # noqa: BLE001 + pytest.fail(reason="invalid PNG data") + + +def test_bad_compression_level(mss_impl: Callable[..., MSS]) -> None: + with mss_impl(compression_level=42) as sct, pytest.raises(Exception, match="Bad compression level"): sct.shot() -def test_compression_level() -> None: - data = b"rgb" * WIDTH * HEIGHT - output = 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 - - -@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"), - ], -) -def test_compression_levels(level: int, checksum: str) -> None: +@pytest.mark.parametrize("level", range(10)) +def test_compression_level(level: int) -> 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 + assert_is_valid_png(raw=raw) 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_is_valid_png(file=output) 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_is_valid_png(raw=raw) + + +# --------------------------------------------------------------------------- +# Helpers and tests for parse_edid +# --------------------------------------------------------------------------- + + +def _make_edid( # noqa: PLR0913 + *, + manufacturer: str = "TST", + product_code: int = 0x1234, + serial_number: int = 0, + manufacture_week: int = 0, + manufacture_year: int = 30, + descriptors: list[tuple[int, int, str]] | None = None, + bad_checksum: bool = False, +) -> bytes: + """Build a minimal 128-byte EDID block.""" + data = bytearray(128) + data[0:8] = b"\x00\xff\xff\xff\xff\xff\xff\x00" + packed = ( + ((ord(manufacturer[0]) - ord("@")) << 10) + | ((ord(manufacturer[1]) - ord("@")) << 5) + | (ord(manufacturer[2]) - ord("@")) + ) + data[8] = (packed >> 8) & 0xFF + data[9] = packed & 0xFF + struct.pack_into(" None: + assert parse_edid(b"") == {} + assert parse_edid(b"\x00" * 64) == {} + assert parse_edid(b"\x00" * 127) == {} + + +def test_parse_edid_invalid_checksum() -> None: + assert parse_edid(_make_edid(bad_checksum=True)) == {} + + +def test_parse_edid_invalid_header() -> None: + data = bytearray(_make_edid()) + data[0] = 0x01 # corrupt the header magic + data[127] = (-sum(data[:127])) % 256 # recompute checksum + assert parse_edid(bytes(data)) == {} + + +def test_parse_edid_basic() -> None: + result = parse_edid(_make_edid(manufacturer="TST", product_code=0x1234)) + assert result["id_legacy"] == "TST1234" + + +def test_parse_edid_manufacture_year_only() -> None: + result = parse_edid(_make_edid(manufacture_week=0, manufacture_year=30)) + assert result["manufacture_year"] == 2020 + assert "manufacture_week" not in result + assert "model_year" not in result + + +def test_parse_edid_manufacture_week_and_year() -> None: + result = parse_edid(_make_edid(manufacture_week=10, manufacture_year=30)) + assert result["manufacture_year"] == 2020 + assert result["manufacture_week"] == 10 + assert "model_year" not in result + + +def test_parse_edid_model_year() -> None: + result = parse_edid(_make_edid(manufacture_week=0xFF, manufacture_year=31)) + assert result["model_year"] == 2021 + assert "manufacture_year" not in result + assert "manufacture_week" not in result + + +def test_parse_edid_serial_number_integer() -> None: + result = parse_edid(_make_edid(serial_number=12345)) + assert result["serial_number"] == 12345 + + +def test_parse_edid_serial_number_not_set() -> None: + result = parse_edid(_make_edid(serial_number=0)) + assert "serial_number" not in result + + +def test_parse_edid_descriptor_serial_number() -> None: + result = parse_edid(_make_edid(descriptors=[(0x48, 0xFF, "SN123456")])) + assert result["serial_number"] == "SN123456" + + +def test_parse_edid_descriptor_display_name() -> None: + result = parse_edid(_make_edid(descriptors=[(0x5A, 0xFC, "Test Monitor")])) + assert result["display_name"] == "Test Monitor" + + +def test_parse_edid_descriptor_string_serial_overrides_integer() -> None: + result = parse_edid(_make_edid(serial_number=99, descriptors=[(0x48, 0xFF, "STRSERIAL")])) + assert result["serial_number"] == "STRSERIAL" diff --git a/src/tests/test_windows.py b/src/tests/test_windows.py index 12273955..27211c19 100644 --- a/src/tests/test_windows.py +++ b/src/tests/test_windows.py @@ -1,14 +1,14 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ + from __future__ import annotations import threading -from typing import Tuple -import mss import pytest -from mss.exception import ScreenShotError + +import mss try: import mss.windows @@ -16,70 +16,63 @@ pytestmark = pytest.mark.skip -def test_implementation(monkeypatch: pytest.MonkeyPatch) -> None: - # Test bad data retrieval - with mss.mss() as sct: - assert isinstance(sct, mss.windows.MSS) # For Mypy - - monkeypatch.setattr(sct.gdi32, "GetDIBits", lambda *_: 0) - with pytest.raises(ScreenShotError): - sct.shot() - - def test_region_caching() -> None: """The region to grab is cached, ensure this is well-done.""" - with mss.mss() as sct: - assert isinstance(sct, mss.windows.MSS) # For Mypy + with mss.MSS() as sct: + assert isinstance(sct, mss.MSS) + assert isinstance(sct._impl, mss.windows.MSSImplWindows) # Grab the area 1 region1 = {"top": 0, "left": 0, "width": 200, "height": 200} sct.grab(region1) - bmp1 = id(sct._handles.bmp) + dib1 = id(sct._impl._dib) - # Grab the area 2, the cached BMP is used + # Grab the area 2, the cached DIB is used # Same sizes but different positions region2 = {"top": 200, "left": 200, "width": 200, "height": 200} sct.grab(region2) - bmp2 = id(sct._handles.bmp) - assert bmp1 == bmp2 + dib2 = id(sct._impl._dib) + assert dib1 == dib2 - # Grab the area 2 again, the cached BMP is used + # Grab the area 2 again, the cached DIB is used sct.grab(region2) - assert bmp2 == id(sct._handles.bmp) + assert dib2 == id(sct._impl._dib) def test_region_not_caching() -> None: """The region to grab is not bad cached previous grab.""" - grab1 = mss.mss() - grab2 = mss.mss() + grab1 = mss.MSS() + grab2 = mss.MSS() - assert isinstance(grab1, mss.windows.MSS) # For Mypy - assert isinstance(grab2, mss.windows.MSS) # For Mypy + assert isinstance(grab1, mss.MSS) # For Mypy + assert isinstance(grab2, mss.MSS) # For Mypy + assert isinstance(grab1._impl, mss.windows.MSSImplWindows) + assert isinstance(grab2._impl, mss.windows.MSSImplWindows) region1 = {"top": 0, "left": 0, "width": 100, "height": 100} region2 = {"top": 0, "left": 0, "width": 50, "height": 1} grab1.grab(region1) - bmp1 = id(grab1._handles.bmp) + dib1 = id(grab1._impl._dib) grab2.grab(region2) - bmp2 = id(grab2._handles.bmp) - assert bmp1 != bmp2 + dib2 = id(grab2._impl._dib) + assert dib1 != dib2 - # Grab the area 1, is not bad cached BMP previous grab the area 2 + # Grab the area 1, is not bad cached DIB previous grab the area 2 grab1.grab(region1) - bmp1 = id(grab1._handles.bmp) - assert bmp1 != bmp2 + dib1 = id(grab1._impl._dib) + assert dib1 != dib2 def run_child_thread(loops: int) -> None: for _ in range(loops): - with mss.mss() as sct: # New sct for every loop + with mss.MSS() as sct: # New sct for every loop sct.grab(sct.monitors[1]) def test_thread_safety() -> None: """Thread safety test for issue #150. - The following code will throw a ScreenShotError exception if thread-safety is not guaranted. + The following code will throw a ScreenShotError exception if thread-safety is not guaranteed. """ # Let thread 1 finished ahead of thread 2 thread1 = threading.Thread(target=run_child_thread, args=(30,)) @@ -90,8 +83,8 @@ def test_thread_safety() -> None: thread2.join() -def run_child_thread_bbox(loops: int, bbox: Tuple[int, int, int, int]) -> None: - with mss.mss() as sct: # One sct for all loops +def run_child_thread_bbox(loops: int, bbox: tuple[int, int, int, int]) -> None: + with mss.MSS() as sct: # One sct for all loops for _ in range(loops): sct.grab(bbox) @@ -99,7 +92,7 @@ def run_child_thread_bbox(loops: int, bbox: Tuple[int, int, int, int]) -> None: def test_thread_safety_regions() -> None: """Thread safety test for different regions. - The following code will throw a ScreenShotError exception if thread-safety is not guaranted. + The following code will throw a ScreenShotError exception if thread-safety is not guaranteed. """ thread1 = threading.Thread(target=run_child_thread_bbox, args=(100, (0, 0, 100, 100))) thread2 = threading.Thread(target=run_child_thread_bbox, args=(100, (0, 0, 50, 1))) diff --git a/src/tests/test_xcb.py b/src/tests/test_xcb.py new file mode 100644 index 00000000..4e3db05d --- /dev/null +++ b/src/tests/test_xcb.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import gc +import platform +from ctypes import ( + POINTER, + Structure, + addressof, + c_int, + c_void_p, + cast, + pointer, + sizeof, +) +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, Callable +from unittest.mock import Mock +from weakref import finalize + +if TYPE_CHECKING: + from collections.abc import Generator + +import pytest + +from mss import MSS +from mss.exception import ScreenShotError +from mss.linux import base, xcb, xgetimage +from mss.linux.xcbhelpers import ( + XcbExtension, + array_from_xcb, + depends_on, + list_from_xcb, +) + +if platform.system().lower() != "linux": + pytestmark = pytest.mark.skip + + +def _force_gc() -> None: + gc.collect() + gc.collect() + + +class _Placeholder: + """Trivial class to test weakrefs""" + + +def test_depends_on_defers_parent_teardown_until_child_collected() -> None: + parent = _Placeholder() + child = _Placeholder() + finalizer_calls: list[str] = [] + finalize(parent, lambda: finalizer_calls.append("parent")) + + depends_on(child, parent) + + del parent + _force_gc() + assert finalizer_calls == [] + + del child + _force_gc() + assert finalizer_calls == ["parent"] + + +def test_ctypes_scalar_finalizer_runs_when_object_collected() -> None: + callback = Mock() + + foo = c_int(42) + finalize(foo, callback) + del foo + _force_gc() + + callback.assert_called_once() + + +class FakeCEntry(Structure): + _fields_ = (("value", c_int),) + + +class FakeParentContainer: + def __init__(self, values: list[int]) -> None: + self.count = len(values) + array_type = FakeCEntry * self.count + self.buffer = array_type(*(FakeCEntry(v) for v in values)) + self.pointer = cast(self.buffer, POINTER(FakeCEntry)) + + +class FakeIterator: + def __init__(self, parent: FakeParentContainer) -> None: + self.parent = parent + self.data = parent.pointer + self.rem = parent.count + + @staticmethod + def next(iterator: FakeIterator) -> None: + iterator.rem -= 1 + if iterator.rem == 0: + return + current_address = addressof(iterator.data.contents) + next_address = current_address + sizeof(FakeCEntry) + iterator.data = cast(c_void_p(next_address), POINTER(FakeCEntry)) + + +def test_list_from_xcb_keeps_parent_alive_until_items_drop() -> None: + parent = FakeParentContainer([1, 2, 3]) + callback = Mock() + finalize(parent, callback) + + items = list_from_xcb(FakeIterator, FakeIterator.next, parent) # type: ignore[arg-type] + assert [item.value for item in items] == [1, 2, 3] + + del parent + _force_gc() + callback.assert_not_called() + + item = items[0] + assert isinstance(item, FakeCEntry) + + del items + _force_gc() + callback.assert_not_called() + + del item + _force_gc() + callback.assert_called_once() + + +def test_array_from_xcb_keeps_parent_alive_until_array_gone() -> None: + parent = _Placeholder() + callback = Mock() + finalize(parent, callback) + + values = [FakeCEntry(1), FakeCEntry(2)] + array_type = FakeCEntry * len(values) + buffer = array_type(*values) + + def pointer_func(_parent: _Placeholder) -> Any: + return cast(buffer, POINTER(FakeCEntry)) + + def length_func(_parent: _Placeholder) -> int: + return len(values) + + array = array_from_xcb(pointer_func, length_func, parent) # type: ignore[arg-type] + assert [entry.value for entry in array] == [1, 2] + + del parent + _force_gc() + callback.assert_not_called() + + item = array[0] + assert isinstance(item, FakeCEntry) + + del array + _force_gc() + callback.assert_not_called() + + del item + _force_gc() + callback.assert_called_once() + + +class _VisualValidationHarness: + """Test utility that supplies deterministic XCB setup data.""" + + def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._monkeypatch = monkeypatch + self.setup = xcb.Setup() + self.screen = xcb.Screen() + self.format = xcb.Format() + self.depth = xcb.Depth() + self.visual = xcb.Visualtype() + self._setup_ptr = pointer(self.setup) + self.connection = xcb.Connection() + + fake_lib = SimpleNamespace( + xcb=SimpleNamespace( + xcb_prefetch_extension_data=lambda *_args, **_kwargs: None, + xcb_get_setup=lambda _conn: self._setup_ptr, + ), + randr_id=XcbExtension(), + xfixes_id=XcbExtension(), + ) + self._monkeypatch.setattr(xcb, "LIB", fake_lib) + self._monkeypatch.setattr(xcb, "connect", lambda _display=None: (self.connection, 0)) + self._monkeypatch.setattr(xcb, "disconnect", lambda _conn: None) + self._monkeypatch.setattr(xcb, "setup_roots", self._setup_roots) + self._monkeypatch.setattr(xcb, "setup_pixmap_formats", self._setup_pixmap_formats) + self._monkeypatch.setattr(xcb, "screen_allowed_depths", self._screen_allowed_depths) + self._monkeypatch.setattr(xcb, "depth_visuals", self._depth_visuals) + + self.reset() + + def reset(self) -> None: + self.setup.image_byte_order = xcb.ImageOrder.LSBFirst + self.screen.root = xcb.Window(1) + self.screen.root_depth = 32 + visual_id = 0x1234 + self.screen.root_visual = xcb.Visualid(visual_id) + + self.format.depth = self.screen.root_depth + self.format.bits_per_pixel = base.SUPPORTED_BITS_PER_PIXEL + self.format.scanline_pad = base.SUPPORTED_BITS_PER_PIXEL + + self.depth.depth = self.screen.root_depth + + self.visual.visual_id = xcb.Visualid(visual_id) + self.visual.class_ = xcb.VisualClass.TrueColor + self.visual.red_mask = base.SUPPORTED_RED_MASK + self.visual.green_mask = base.SUPPORTED_GREEN_MASK + self.visual.blue_mask = base.SUPPORTED_BLUE_MASK + + self.screens = [self.screen] + self.pixmap_formats = [self.format] + self.depths = [self.depth] + self.visuals = [self.visual] + + def _setup_roots(self, _setup: xcb.Setup) -> list[xcb.Screen]: + return self.screens + + def _setup_pixmap_formats(self, _setup: xcb.Setup) -> list[xcb.Format]: + return self.pixmap_formats + + def _screen_allowed_depths(self, _screen: xcb.Screen) -> list[xcb.Depth]: + return self.depths + + def _depth_visuals(self, _depth: xcb.Depth) -> list[xcb.Visualtype]: + return self.visuals + + +@pytest.fixture +def visual_validation_env(monkeypatch: pytest.MonkeyPatch) -> _VisualValidationHarness: + return _VisualValidationHarness(monkeypatch) + + +#### intern_atom tests + + +class TestInternAtom: + """Tests for xcb.intern_atom and the _ATOM_CACHE mechanism.""" + + @pytest.fixture(autouse=True) + def setup_intern_atom(self) -> Generator[None, None, None]: + self.conn, _ = xcb.connect() + yield + xcb.disconnect(self.conn) + + def _mock_xcb_intern_atom(self, monkeypatch: pytest.MonkeyPatch, atom_value: int) -> Mock: + """Patch LIB.xcb.xcb_intern_atom to return a fake reply with the given atom value.""" + fake_reply = SimpleNamespace(atom=SimpleNamespace(value=atom_value)) + fake_cookie = Mock() + fake_cookie.reply.return_value = fake_reply + mock = Mock(return_value=fake_cookie) + monkeypatch.setattr(xcb.LIB.xcb, "xcb_intern_atom", mock) + return mock + + def test_predefined_atom_skips_xcb(self, monkeypatch: pytest.MonkeyPatch) -> None: + mock = self._mock_xcb_intern_atom(monkeypatch, 0) + atom = xcb.intern_atom(self.conn, "PRIMARY") + assert atom == xcb.Atom(1) + mock.assert_not_called() + + def test_cache_miss_calls_xcb_and_caches_result(self, monkeypatch: pytest.MonkeyPatch) -> None: + mock = self._mock_xcb_intern_atom(monkeypatch, 100) + cache_key = addressof(self.conn) + atom = xcb.intern_atom(self.conn, "_NET_WM_NAME") + assert atom == xcb.Atom(100) + mock.assert_called_once() + assert xcb._ATOM_CACHE[cache_key]["_NET_WM_NAME"] == xcb.Atom(100) + + def test_cache_hit_skips_xcb(self, monkeypatch: pytest.MonkeyPatch) -> None: + mock = self._mock_xcb_intern_atom(monkeypatch, 0) + # xcb.connect() in setup_intern_atom guarantees a cache entry for self.conn. + xcb._ATOM_CACHE[addressof(self.conn)]["_NET_WM_NAME"] = xcb.Atom(100) + atom = xcb.intern_atom(self.conn, "_NET_WM_NAME") + assert atom == xcb.Atom(100) + mock.assert_not_called() + + def test_only_if_exists_returns_none_when_missing(self) -> None: + atom = xcb.intern_atom(self.conn, "_MSS_TEST_NONEXISTENT_ATOM_12345", only_if_exists=True) + assert atom is None + + def test_raises_when_missing_and_not_only_if_exists(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Exercises the "shouldn't be possible" code path where the server returns 0 with only_if_exists=False. + self._mock_xcb_intern_atom(monkeypatch, 0) + with pytest.raises(xcb.XError, match="X server failed to intern atom"): + xcb.intern_atom(self.conn, "_NET_NONEXISTENT") + + def test_pointer_connection_uses_correct_cache_key(self) -> None: + atom = xcb.intern_atom(pointer(self.conn), "_NET_WM_NAME") + assert atom is not None + assert addressof(self.conn) in xcb._ATOM_CACHE + + +def test_atom_cache_lifecycle() -> None: + """connect() initializes and disconnect() clears the per-connection atom cache entry.""" + before = set(xcb._ATOM_CACHE) + conn, _ = xcb.connect() + cache_key = addressof(conn) + assert cache_key in xcb._ATOM_CACHE + assert xcb._ATOM_CACHE[cache_key] == {} + xcb.disconnect(conn) + assert cache_key not in xcb._ATOM_CACHE + assert set(xcb._ATOM_CACHE) == before + + +def test_xgetimage_visual_validation_accepts_default_setup(visual_validation_env: _VisualValidationHarness) -> None: + visual_validation_env.reset() + mss_instance = MSS(backend="xgetimage") + try: + assert isinstance(mss_instance, MSS) + assert isinstance(mss_instance._impl, xgetimage.MSSImplXGetImage) + finally: + mss_instance.close() + + +@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"), + ], +) +def test_xgetimage_visual_validation_failures( + visual_validation_env: _VisualValidationHarness, + mutator: Callable[[_VisualValidationHarness], None], + message: str, +) -> None: + mutator(visual_validation_env) + with pytest.raises(ScreenShotError, match=message): + MSS(backend="xgetimage") diff --git a/src/tests/third_party/__init__.py b/src/tests/third_party/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/third_party/test_numpy.py b/src/tests/third_party/test_numpy.py new file mode 100644 index 00000000..c06e6180 --- /dev/null +++ b/src/tests/third_party/test_numpy.py @@ -0,0 +1,18 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from collections.abc import Callable + +import pytest + +from mss import MSS + +np = pytest.importorskip("numpy", reason="Numpy module not available.") + + +def test_numpy(mss_impl: Callable[..., MSS]) -> None: + box = {"top": 0, "left": 0, "width": 10, "height": 10} + with mss_impl() as sct: + img = np.array(sct.grab(box)) + assert len(img) == 10 diff --git a/src/tests/test_third_party.py b/src/tests/third_party/test_pil.py similarity index 51% rename from src/tests/test_third_party.py rename to src/tests/third_party/test_pil.py index b5443c74..274fd049 100644 --- a/src/tests/test_third_party.py +++ b/src/tests/third_party/test_pil.py @@ -1,38 +1,22 @@ """This is part of the MSS Python's module. Source: https://github.com/BoboTiG/python-mss. """ + import itertools -import os -import os.path +from collections.abc import Callable +from pathlib import Path import pytest -from mss import mss - -try: - import numpy as np -except (ImportError, RuntimeError): - # RuntimeError on Python 3.9 (macOS): Polyfit sanity test emitted a warning, ... - np = None - -try: - from PIL import Image -except ImportError: - Image = None +from mss import MSS -@pytest.mark.skipif(np is None, reason="Numpy module not available.") -def test_numpy(pixel_ratio: int) -> None: - box = {"top": 0, "left": 0, "width": 10, "height": 10} - with mss(display=os.getenv("DISPLAY")) as sct: - img = np.array(sct.grab(box)) - assert len(img) == 10 * pixel_ratio +Image = pytest.importorskip("PIL.Image", reason="PIL module not available.") -@pytest.mark.skipif(Image is None, reason="PIL module not available.") -def test_pil() -> None: +def test_pil(mss_impl: Callable[..., MSS]) -> None: width, height = 16, 16 box = {"top": 0, "left": 0, "width": width, "height": height} - with mss(display=os.getenv("DISPLAY")) as sct: + with mss_impl() as sct: sct_img = sct.grab(box) img = Image.frombytes("RGB", sct_img.size, sct_img.rgb) @@ -42,15 +26,15 @@ 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() -@pytest.mark.skipif(Image is None, reason="PIL module not available.") -def test_pil_bgra() -> None: +def test_pil_bgra(mss_impl: Callable[..., MSS]) -> None: width, height = 16, 16 box = {"top": 0, "left": 0, "width": width, "height": height} - with mss(display=os.getenv("DISPLAY")) as sct: + with mss_impl() as sct: sct_img = sct.grab(box) img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") @@ -60,15 +44,15 @@ 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() -@pytest.mark.skipif(Image is None, reason="PIL module not available.") -def test_pil_not_16_rounded() -> None: +def test_pil_not_16_rounded(mss_impl: Callable[..., MSS]) -> None: width, height = 10, 10 box = {"top": 0, "left": 0, "width": width, "height": height} - with mss(display=os.getenv("DISPLAY")) as sct: + with mss_impl() as sct: sct_img = sct.grab(box) img = Image.frombytes("RGB", sct_img.size, sct_img.rgb) @@ -78,5 +62,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() diff --git a/src/xcbproto/README.md b/src/xcbproto/README.md new file mode 100644 index 00000000..797f8f87 --- /dev/null +++ b/src/xcbproto/README.md @@ -0,0 +1,39 @@ +# xcbproto Directory + +This directory contains the tooling and protocol definitions used to generate Python bindings for XCB (X C Binding). + +## Overview + +- **`gen_xcb_to_py.py`**: Code generator that produces Python/ctypes bindings from XCB protocol XML files. +- **`*.xml`**: Protocol definition files vendored from the upstream [xcbproto](https://gitlab.freedesktop.org/xorg/proto/xcbproto) repository. These describe the X11 core protocol and extensions (RandR, Render, XFixes, etc.). + +## Workflow + +The generator is a **maintainer tool**, not part of the normal build process: + +1. When the project needs new XCB requests or types, a maintainer edits the configuration in `gen_xcb_to_py.py` (see `TYPES` and `REQUESTS` dictionaries near the top). +2. The maintainer runs the generator: + + ```bash + python src/xcbproto/gen_xcb_to_py.py + ``` + +3. The generator reads the XML protocol definitions and emits `xcbgen.py`. +4. The maintainer ensures that this worked correctly, and runs `ruff check --fix` and `ruff format` on the emitted file. +5. The maintainer moves the file to `src/mss/linux/xcbgen.py`. +6. The generated `xcbgen.py` is committed to version control and distributed with the package, so end users never need to run the generator. + +## Protocol XML Files + +The `*.xml` files are **unmodified copies** from the upstream xcbproto project. They define the wire protocol and data structures used by libxcb. Do not edit these files. + +## Why Generate Code? + +The XCB C library exposes thousands of protocol elements. Rather than hand-write ctypes bindings for every structure and request, we auto-generate only the subset we actually use. This keeps the codebase lean while ensuring the bindings exactly match the upstream protocol definitions. + +## Dependencies + +- **lxml**: Required to parse the XML protocol definitions. +- **Python 3.12+**: The generator uses modern Python features. + +Note that end users do **not** need lxml; it's only required if you're regenerating the bindings. diff --git a/src/xcbproto/gen_xcb_to_py.py b/src/xcbproto/gen_xcb_to_py.py new file mode 100755 index 00000000..648a5e4e --- /dev/null +++ b/src/xcbproto/gen_xcb_to_py.py @@ -0,0 +1,1279 @@ +#!/usr/bin/env python3 +"""Generate Python bindings for selected XCB protocol elements. + +Only the portions of the protocol explicitly requested are generated. +Types referenced by those requests are pulled in transitively. + +The emitted code includes the following: + +* Enums (as IntEnum classes) +* Typedefs, XID types, and XID unions (as CData subclasses) +* Structs, including replies, but not requests (as ctypes Structs) + * [Internal] For structs that are variable-length: + * [Internal] The iterator class + * [Internal] Initializers for the ctypes "foo_next" function + * For structs that contain lists: + * If the list elements are constant length: + * A "outerstruct_listname" function returning a ctypes array + * [Internal] Initializers for the "outerstruct_listname" and + "outerstruct_listname_length" ctypes functions + * Otherwise (the list elements are variable length): + * A "outerstruct_listname" function returning a Python list of + ctypes structs + * [Internal] Initializers for the "outerstruct_listname_iterator" + ctypes function +* For requests: + * [Internal] For those with replies, a cookie class + * A wrapper function that will block and error-test the request, + returning the reply struct (or None, for void-returning functions) +""" + +from __future__ import annotations + +import argparse +import builtins +import keyword +import re +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from textwrap import dedent, indent +from typing import TYPE_CHECKING + +from lxml import etree as ET # noqa: N812 (traditional name) + +if TYPE_CHECKING: + import io + from collections.abc import Generator, Iterable, Mapping + from typing import Any, Self + +# ---------------------------------------------------------------------------- +# Configuration of what we want to generate + +TYPES: dict[str, list[str]] = { + "xproto": ["Setup"], +} + +REQUESTS: dict[str, list[str]] = { + "xproto": [ + "GetGeometry", + "GetImage", + "GetProperty", + # We handle InternAtom specially in xcb.py: it's the only request we use that includes a list (the name) in + # the request. Rather than writing the code for autogeneration, we just open-code that one. + "NoOperation", + ], + "randr": [ + "QueryVersion", + "GetScreenResources", + "GetScreenResourcesCurrent", + "GetCrtcInfo", + "GetOutputInfo", + "GetOutputPrimary", + "GetOutputProperty", + "GetMonitors", + ], + "render": [ + "QueryVersion", + "QueryPictFormats", + ], + "shm": [ + "QueryVersion", + "GetImage", + "AttachFd", + "CreateSegment", + "Detach", + ], + "xfixes": [ + "QueryVersion", + "GetCursorImage", + ], +} + +# ---------------------------------------------------------------------------- +# Constant data used by the generator + +PRIMITIVE_CTYPES: dict[str, str] = { + "CARD8": "c_uint8", + "CARD16": "c_uint16", + "CARD32": "c_uint32", + "CARD64": "c_uint64", + "INT8": "c_int8", + "INT16": "c_int16", + "INT32": "c_int32", + "INT64": "c_int64", + "BYTE": "c_uint8", + "BOOL": "c_uint8", + "char": "c_char", + "void": "None", +} + +INT_CTYPES = {"c_int", "c_int8", "c_int16", "c_int32", "c_int64", "c_uint8", "c_uint16", "c_uint32", "c_uint64"} + +EIGHT_BIT_TYPES = { + "c_int8", + "c_uint8", + "c_char", +} + +XCB_LENGTH_TYPE = "c_int" + +RESERVED_NAMES = set(keyword.kwlist) | set(keyword.softkwlist) | set(dir(builtins)) + +GENERATED_HEADER = """# Auto-generated by gen_xcb_to_py.py - do not edit manually. + +# Since many of the generated functions have many parameters, we disable the pylint warning about too many arguments. +# ruff: noqa: PLR0913 + +from __future__ import annotations + +from ctypes import ( + POINTER, + Array, + Structure, + Union, + _Pointer, + c_char, + c_char_p, + c_double, + c_float, + c_int, + c_int8, + c_int16, + c_int32, + c_int64, + c_uint, + c_uint8, + c_uint16, + c_uint32, + c_uint64, + c_void_p, +) +from enum import IntEnum + +from mss.linux.xcbhelpers import ( + LIB, + XID, + Connection, + VoidCookie, + array_from_xcb, + initialize_xcb_typed_func, + list_from_xcb, +) +""" + + +# ---------------------------------------------------------------------------- +# Utility helpers. + + +class GenerationError(RuntimeError): + """Raised when the XML describes a construct this generator cannot handle yet.""" + + def __init__(self, message: str, *, element: ET._Element | None = None) -> None: + super().__init__(message) + self._element = element + + def __str__(self) -> str: + base = super().__str__() + if self._element is None: + return base + + element_base = getattr(self._element, "base", None) + element_line = getattr(self._element, "sourceline", None) + if element_base and element_line: + return f"{element_base}:{element_line}: {base}" + return base + + +@contextmanager +def parsing_note(description: str, element: ET._Element | None = None) -> Generator[None]: + """Context manager to add parsing context to exceptions. + + Use when parsing XML elements to provide better error messages. + + Example: + with parsing_note("while parsing struct Foo", element): + ... + """ + try: + yield + except Exception as exc: + note = description + if element is not None: + base = getattr(element, "base", None) + line = getattr(element, "sourceline", None) + if base and line: + note = f"{description} at {base}:{line}" + exc.add_note(note) + raise + + +class LazyDefn: + """Base class for lazily parsed protocol definitions. + + We lazily parse certain definitions so that we only need to support parsing + the features that are actually used by the requested types and requests, + rather than the entire XCB spec. + """ + + def __init__(self, *, protocol: str, name: str, element: ET._Element) -> None: + self.protocol = protocol + self.name = name + self._element = element + self._parsed = False + self._parsing = False + + def _parse(self) -> None: + raise NotImplementedError + + def _ensure_parsed(self) -> None: + if self._parsed: + return + if self._parsing: + msg = f"Re-entrant parse detected for {self!r}" + raise RuntimeError(msg) + self._parsing = True + try: + self._parse() + finally: + self._parsing = False + self._parsed = True + + def parse(self) -> None: + self._ensure_parsed() + + def __getattr__(self, name: str) -> Any: + self._ensure_parsed() + if name in self.__dict__: + return self.__dict__[name] + msg = f"{type(self).__name__!r} object has no attribute {name!r}" + raise AttributeError(msg) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.protocol!r}, {self.name!r})" + + +def resolve_primitive(name: str) -> str | None: + upper = name.upper() + if upper in PRIMITIVE_CTYPES: + return PRIMITIVE_CTYPES[upper] + return PRIMITIVE_CTYPES.get(name) + + +# ---------------------------------------------------------------------------- +# Parsed protocol structures +# +# These are the structures that represent the parsed XML protocol definitions. + + +@dataclass +class EnumerationItem: + name: str + value: int + + +@dataclass +class EnumDefn: + protocol: str + name: str + items: list[EnumerationItem] + + +@dataclass +class XidTypeDefn: + protocol: str + name: str + + +@dataclass +class XidUnionDefn(LazyDefn): + protocol: str + name: str + types: list[str] + + +@dataclass +class TypedefDefn: + protocol: str + name: str + oldname: str + + +@dataclass +class Field: + name: str + type: str + enum: str | None = None + mask: str | None = None + + +@dataclass +class Pad: + bytes: int | None = None + align: int | None = None + + +@dataclass +class ListField: + name: str + type: str + enum: str | None = None + + +@dataclass +class FdField: + name: str + + +StructMember = Field | Pad | ListField | FdField + + +class StructLikeDefn(LazyDefn): + """Base class for struct-like definitions. + + This includes structs, requests, and replies, which are all similarly + structured. + """ + + def __init__(self, protocol: str, name: str, element: ET._Element) -> None: + super().__init__(protocol=protocol, name=name, element=element) + # Fields, padding, lists, in their original order + self.members: list[StructMember] + + @property + def fields(self) -> list[Field]: + self._ensure_parsed() + return [x for x in self.members if isinstance(x, Field)] + + @property + def lists(self) -> list[ListField]: + self._ensure_parsed() + return [x for x in self.members if isinstance(x, ListField)] + + def _parse_child(self, child: ET._Element) -> None: + """Parse a single child element of the struct-like definition. + + Subclasses are expected to override this to handle additional child + elements, but should call super() to handle the common ones. + """ + if isinstance(child, ET._Comment): # noqa: SLF001 + return + match child.tag: + case "field": + self.members.append( + Field( + name=child.attrib["name"], + type=child.attrib["type"], + enum=child.attrib.get("enum"), + mask=child.attrib.get("mask"), + ) + ) + case "fd": + self.members.append(FdField(name=child.attrib["name"])) + case "pad": + self.members.append(parse_pad(child)) + case "list": + self.members.append(parse_list(child)) + case "doc": + return + case _: + msg = f"Unsupported member {child.tag} in {self.protocol}:{self.name}" + raise GenerationError(msg, element=child) + + def _parse(self) -> None: + with parsing_note(f"while parsing {self.protocol}:{self.name}", self._element): + self.members = [] + for child in self._element: + self._parse_child(child) + + +class StructDefn(StructLikeDefn): + pass + + +class ReplyDefn(StructLikeDefn): + # Note that replies don't have their own name, so we use the request name. + pass + + +class RequestDefn(StructLikeDefn): + def __init__(self, protocol: str, name: str, element: ET._Element) -> None: + super().__init__(protocol=protocol, name=name, element=element) + self.reply: ReplyDefn | None + + def _parse_child(self, child: ET._Element) -> None: + if child.tag == "reply": + self.reply = ReplyDefn(self.protocol, self.name, child) + else: + super()._parse_child(child) + + def _parse(self) -> None: + self.reply = None + super()._parse() + + +TypeDefn = XidTypeDefn | XidUnionDefn | TypedefDefn | StructLikeDefn + + +# ---------------------------------------------------------------------------- +# Protocol container and lookups + + +@dataclass +class ProtocolModule: + name: str + version: tuple[int, int] | None + enums: dict[str, EnumDefn] = field(default_factory=dict) + types: dict[str, TypeDefn] = field(default_factory=dict) + requests: dict[str, RequestDefn] = field(default_factory=dict) + imports: list[str] = field(default_factory=list) + + +def parse_enum(protocol: str, elem: ET.Element) -> EnumDefn: + name = elem.attrib["name"] + with parsing_note(f"while parsing enum {name}", elem): + items: list[EnumerationItem] = [] + for item in elem.findall("item"): + if (value := item.find("value")) is not None: + items.append(EnumerationItem(item.attrib["name"], int(value.text, 0))) + elif (bit := item.find("bit")) is not None: + items.append(EnumerationItem(item.attrib["name"], 1 << int(bit.text, 0))) + else: + msg = f"Unsupported enum item in {protocol}:{name}:{item}" + raise GenerationError(msg, element=item) + return EnumDefn(protocol, name, items) + + +def parse_xidunion(protocol: str, elem: ET.Element) -> XidUnionDefn: + name = elem.attrib["name"] + with parsing_note(f"while parsing xidunion {name}", elem): + members: list[str] = [] + for child in elem: + if isinstance(child, ET._Comment): # noqa: SLF001 + continue + if child.tag == "type": + if child.text is None: + msg = "xidunion type entry missing text" + raise GenerationError(msg, element=child) + members.append(child.text.strip()) + elif child.tag == "doc": + continue + else: + msg = f"Unsupported xidunion member {child.tag} in {protocol}:{name}" + raise GenerationError(msg, element=child) + if not members: + msg = f"xidunion {protocol}:{name} must include at least one type" + raise GenerationError(msg, element=elem) + return XidUnionDefn(protocol, name, members) + + +def parse_list(elem: ET.Element) -> ListField: + return ListField(elem.attrib["name"], elem.attrib["type"], elem.attrib.get("enum")) + + +def parse_pad(elem: ET.Element) -> Pad: + with parsing_note("while parsing pad", elem): + bytes_attr = elem.attrib.get("bytes") + align_attr = elem.attrib.get("align") + if (bytes_attr is None) == (align_attr is None): + msg = "Pad must specify exactly one of 'bytes' or 'align'" + raise GenerationError(msg, element=elem) + if bytes_attr is not None: + return Pad(bytes=int(bytes_attr, 0)) + return Pad(align=int(align_attr, 0)) + + +def parse_protocol(path: Path) -> ProtocolModule: # noqa: PLR0912, PLR0915 + with parsing_note(f"while parsing protocol {path.name}"): + tree = ET.parse(path) + root = tree.getroot() + protocol = root.attrib["header"] + if "major-version" in root.attrib: + version = (int(root.attrib["major-version"]), int(root.attrib["minor-version"])) + else: + version = None + module = ProtocolModule(name=protocol, version=version) + for child in root: + if isinstance(child, ET._Comment): # noqa: SLF001 + continue + match child.tag: + case "enum": + if child.attrib["name"] in module.enums: + msg = f"Duplicate enum {child.attrib['name']} in protocol {protocol}" + raise GenerationError(msg, element=child) + module.enums[child.attrib["name"]] = parse_enum(protocol, child) + case "typedef": + if child.attrib["newname"] in module.types: + msg = f"Duplicate type {child.attrib['newname']} in protocol {protocol}" + raise GenerationError(msg, element=child) + module.types[child.attrib["newname"]] = TypedefDefn( + protocol, child.attrib["newname"], child.attrib["oldname"] + ) + case "xidtype": + if child.attrib["name"] in module.types: + msg = f"Duplicate type {child.attrib['name']} in protocol {protocol}" + raise GenerationError(msg, element=child) + module.types[child.attrib["name"]] = XidTypeDefn(protocol, child.attrib["name"]) + case "xidunion": + if child.attrib["name"] in module.types: + msg = f"Duplicate type {child.attrib['name']} in protocol {protocol}" + raise GenerationError(msg, element=child) + module.types[child.attrib["name"]] = parse_xidunion(protocol, child) + case "struct": + if child.attrib["name"] in module.types: + msg = f"Duplicate type {child.attrib['name']} in protocol {protocol}" + raise GenerationError(msg, element=child) + module.types[child.attrib["name"]] = StructDefn(protocol, child.attrib["name"], child) + case "request": + if child.attrib["name"] in module.requests: + msg = f"Duplicate request {child.attrib['name']} in protocol {protocol}" + raise GenerationError(msg, element=child) + module.requests[child.attrib["name"]] = RequestDefn(protocol, child.attrib["name"], child) + case "import": + # There's actually some leeway in how the imports are resolved. We only require the imported + # module to have been loaded if we need to check it for a type or enum. Since nearly everything + # is loaded from the same file or from xproto, it's rarely needed to do that explicitly. + module.imports.append(child.text.strip()) + case "union": + # We presently just don't use any unions (just xidunion). If they get used by something else, + # we'll end up raising an error at that time. + pass + case "error" | "errorcopy": + # We don't need any specialized error data. + pass + case "event" | "eventcopy": + # We don't use any events at present. + pass + case _: + msg = f"Unknown element {child.tag} in protocol {protocol}" + raise GenerationError(msg, element=child) + return module + + +class ProtocolRegistry: + """Holds every protocol module and provides lookup helpers.""" + + # This gets passed around a lot. It might be better to put it in a contextvar, if it gets burdensome. + + def __init__(self, proto_dir: Path) -> None: + self.modules: dict[str, ProtocolModule] = {} + self._load_all(proto_dir) + + def _load_all(self, proto_dir: Path) -> None: + for path in sorted(proto_dir.glob("*.xml")): + module = parse_protocol(path) + self.modules[module.name] = module + + def resolve_type(self, protocol: str, name: str) -> TypeDefn: + # Prefer the supplied protocol, then imports. + module = self.modules.get(protocol) + if not module: + msg = f"Unknown protocol {protocol} when resolving type {name}" + raise GenerationError(msg) + if name in module.types: + return module.types[name] + for imported_modname in module.imports: + imported_module = self.modules.get(imported_modname) + if imported_module is None: + msg = f"Module {protocol} imports {imported_modname}, which is not loaded" + raise GenerationError(msg) + if name in imported_module.types: + return imported_module.types[name] + msg = f"Unknown type {name} referenced from {protocol}" + raise GenerationError(msg) + + def resolve_enum(self, protocol: str, name: str) -> EnumDefn: + module = self.modules.get(protocol) + if not module: + msg = f"Unknown protocol {protocol} when resolving enum {name}" + raise GenerationError(msg) + if name in module.enums: + return module.enums[name] + for imported_modname in module.imports: + imported_module = self.modules.get(imported_modname) + if imported_module is None: + msg = f"Module {protocol} imports {imported_modname}, which is not loaded" + raise GenerationError(msg) + if name in imported_module.enums: + return imported_module.enums[name] + msg = f"Unknown enum {name} referenced from {protocol}" + raise GenerationError(msg) + + def resolve_request(self, protocol: str, name: str) -> RequestDefn: + if protocol not in self.modules: + msg = f"Unknown protocol {protocol} when resolving request {name}" + raise GenerationError(msg) + rv = self.modules[protocol].requests.get(name) + if rv is None: + msg = f"Request {protocol}:{name} not found" + raise GenerationError(msg) + return rv + + +# ---------------------------------------------------------------------------- +# Dependency analysis + + +@dataclass +class TopoSortResult: + enums: list[EnumDefn] + types: list[TypeDefn] + requests: list[RequestDefn] + + +def toposort_requirements( + registry: ProtocolRegistry, + type_requirements: Mapping[str, list[str]], + request_requirements: Mapping[str, list[str]], +) -> TopoSortResult: + rv = TopoSortResult([], [], []) + seen_types: set[tuple[str, str]] = set() + + def appendnew[T](collection: list[T], item: T) -> None: + if item not in collection: + collection.append(item) + + def require_member(protocol: str, member: StructMember) -> None: + if isinstance(member, (Field, ListField)): + require_type(protocol, member.type) + if member.enum: + enum = registry.resolve_enum(protocol, member.enum) + appendnew(rv.enums, enum) + elif isinstance(member, (FdField, Pad)): + pass + else: + msg = f"Unrecognized struct member {member}" + raise GenerationError(msg) + + def require_structlike(protocol: str, entry: StructLikeDefn) -> None: + for member in entry.members: + require_member(protocol, member) + + def require_type(protocol: str, name: str) -> None: + primitive = resolve_primitive(name) + if primitive: + return + entry = registry.resolve_type(protocol, name) + require_resolved_type(entry) + + def require_resolved_type(entry: TypeDefn) -> None: + key = (entry.protocol, entry.name) + if key in seen_types: + return + seen_types.add(key) + if isinstance(entry, XidUnionDefn): + # We put the union first as an XID, so that the subtypes can be derived from it. + appendnew(rv.types, entry) + for typ in entry.types: + require_type(protocol, typ) + elif isinstance(entry, StructLikeDefn): + require_structlike(entry.protocol, entry) + # The request types should all be handled by a different mechanism. + assert not isinstance(entry, RequestDefn) # noqa: S101 + appendnew(rv.types, entry) + else: + appendnew(rv.types, entry) + + for protocol, names in type_requirements.items(): + for name in names: + require_type(protocol, name) + + for protocol, names in request_requirements.items(): + for name in names: + request = registry.resolve_request(protocol, name) + require_structlike(protocol, request) + if request.reply: + require_resolved_type(request.reply) + appendnew(rv.requests, request) + + return rv + + +# ---------------------------------------------------------------------------- +# Code generation + + +@dataclass +class FuncDecl: + protocol: str + name: str + argtypes: list[str] + restype: str + + +class CodeWriter: + def __init__(self, fh: io.TextIOBase) -> None: + self._fh = fh + self._indent = 0 + + def write(self, line: str = "") -> None: + if line: + self._fh.write(indent(line, " " * self._indent) + "\n") + else: + self._fh.write("\n") + + @contextmanager + def indent(self) -> Generator[Self]: + self._indent += 1 + yield self + self._indent -= 1 + + +# Utilities + + +def type_is_variable(type_: TypeDefn) -> bool: + return isinstance(type_, StructLikeDefn) and bool(type_.lists) + + +def is_eight_bit(registry: ProtocolRegistry, protocol: str, name: str) -> bool: + primitive = resolve_primitive(name) + if primitive: + return primitive in EIGHT_BIT_TYPES + defn = registry.resolve_type(protocol, name) + if isinstance(defn, TypedefDefn): + return is_eight_bit(registry, defn.protocol, defn.oldname) + return False + + +def lib_for_proto(protocol: str) -> str: + if protocol == "xproto": + return "xcb" + return protocol + + +# Naming + + +def camel_case(name: str, protocol: str | None = None) -> str: + prefix = "" if protocol in {"xproto", None} else camel_case(protocol) # type: ignore[arg-type] + if not name.isupper() and not name.islower(): + # It's already in camel case. + return prefix + name + camel_name = name.title().replace("_", "") + return prefix + camel_name + + +def snake_case(name: str, protocol: str | None = None) -> str: + prefix = ( + "" + if protocol + in { + "xproto", + None, + } + else f"{snake_case(protocol)}_" # type: ignore[arg-type] + ) + if name.islower(): + return prefix + name + s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name) + s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1) + s3 = s2.lower() + return f"{prefix}{s3}" + + +def format_enum_name(enum: EnumDefn) -> str: + """Format an enum class name suitable for Python. + + libxcb doesn't define enum names, but we don't use its .h files + anyway. + + XCB enums are usually already in camel case. We usually just + prepend the extension name if it's not xproto. + + Examples: + * xproto VisualClass -> VisualClass + * randr Rotation -> RandrRotation + """ + return camel_case(enum.name, enum.protocol) + + +def format_enum_item_name(enum_item: EnumerationItem) -> str: + """Format an entry name in an enum. + + XCB enums are typically already in camel case, which we preserve. + If there are already both upper and lower case, then we also + preserve underscores. + + Examles: + * DirectColor -> DirectColor + * Rotate_0 -> Rotate_0 + """ + rv = camel_case(enum_item.name) + return rv if rv not in RESERVED_NAMES else f"{rv}_" + + +def format_type_name(typedefn: TypeDefn) -> str: + """Format a type name suitable for Python. + + libxcb defines type names with the C snake case convention, but we + don't use its .h files anyway. + + We will change all-caps to title case, and prepend the extension + if it's not xproto. + + Examples: + * VISUALTYPE -> Visualtype + * SetupFailed -> SetupFailed + * ScreenSize -> RandrScreenSize + """ + base_name = camel_case(typedefn.name, typedefn.protocol) + if isinstance(typedefn, ReplyDefn): + return f"{base_name}Reply" + return base_name + + +def format_field_name(field: Field | FdField) -> str: + name = field.name + return f"{name}_" if name in RESERVED_NAMES else name + + +def format_function_name(name: str, protocol: str | None = None) -> str: + return snake_case(name, protocol) + + +# Version constants + + +def emit_versions(writer: CodeWriter, registry: ProtocolRegistry) -> None: + writer.write() + for module in registry.modules.values(): + if module.version is None: + continue + const_prefix = module.name.upper() + writer.write(f"{const_prefix}_MAJOR_VERSION = {module.version[0]}") + writer.write(f"{const_prefix}_MINOR_VERSION = {module.version[1]}") + + +# Enums + + +def emit_enums(writer: CodeWriter, _registry: ProtocolRegistry, enums: Iterable[EnumDefn]) -> None: + enums = sorted(enums, key=lambda x: (x.protocol, x.name)) + writer.write() + writer.write("# Enum classes") + for defn in enums: + with parsing_note(f"while emitting enum {defn.protocol}:{defn.name}"): + class_name = format_enum_name(defn) + writer.write() + writer.write(f"class {class_name}(IntEnum):") + with writer.indent(): + for item in defn.items: + item_name = format_enum_item_name(item) + writer.write(f"{item_name} = {item.value}") + + +# Simple (non-struct-like) types + + +def python_type_for(registry: ProtocolRegistry, protocol: str, name: str) -> str: + primitive = resolve_primitive(name) + if primitive: + return primitive + entry = registry.resolve_type(protocol, name) + return format_type_name(entry) + + +# xidtypes interact with xidunions in kind of a backwards order. This is to make it possible to pass a Window to a +# function that expects a Drawable. Example output: +# . class Drawable(XID): pass +# . class Window(Drawable): pass +# . class Pixmap(Drawable): pass +# We can't use "Drawable = Window | Pixmap" because ctypes doesn't know what to do with that when used in argtypes. +def emit_xid( + writer: CodeWriter, + _registry: ProtocolRegistry, + entry: XidTypeDefn | XidUnionDefn, + derived_from: XidUnionDefn | None, +) -> None: + class_name = format_type_name(entry) + derived_from_name = format_type_name(derived_from) if derived_from is not None else "XID" + writer.write() + writer.write(f"class {class_name}({derived_from_name}):") + with writer.indent(): + writer.write("pass") + + +def emit_xidunion(writer: CodeWriter, registry: ProtocolRegistry, entry: XidUnionDefn) -> None: + emit_xid(writer, registry, entry, None) + + +def emit_typedef(writer: CodeWriter, registry: ProtocolRegistry, entry: TypedefDefn) -> None: + class_name = format_type_name(entry) + base = python_type_for(registry, entry.protocol, entry.oldname) + writer.write() + writer.write(f"class {class_name}({base}):") + with writer.indent(): + writer.write( + dedent(f""" + def __eq__(self, other: object) -> bool: + if isinstance(other, {class_name}): + return self.value == other.value + return NotImplemented + + def __hash__(self) -> int: + return hash(self.value) + """) + ) + + +# Struct-like types + + +def emit_structlike( + writer: CodeWriter, + registry: ProtocolRegistry, + entry: StructLikeDefn, + members: list[tuple[str, str] | StructMember] | None = None, +) -> list[FuncDecl]: + class_name = format_type_name(entry) + rv: list[FuncDecl] = [] + + # The member list can be overridden by the caller: a reply structure needs to have the generic reply structure + # (like the sequence number) alongside it, and the padding byte may cause reordering. + if members is None: + members = entry.members # type: ignore[assignment] + assert members is not None # noqa: S101 + + writer.write() + writer.write(f"class {class_name}(Structure):") + with writer.indent(): + # Fields are name, python_type + field_entries: list[tuple[str, str]] = [] + seen_list: bool = False + pad_index = 0 + for member in members: + if isinstance(member, tuple): + field_entries.append(member) + elif isinstance(member, Field): + if seen_list: + msg = f"Structure {entry.protocol}:{entry.name} has fields after lists, which is unsupported" + raise GenerationError(msg) + name = format_field_name(member) + type_expr = python_type_for(registry, entry.protocol, member.type) + field_entries.append((name, type_expr)) + elif isinstance(member, Pad): + if seen_list: + continue + if member.align is not None or member.bytes is None: + msg = f"Struct {entry.protocol}:{entry.name} uses align-based padding, which is unsupported" + raise GenerationError(msg) + name = f"pad{pad_index}" + pad_index += 1 + field_entries.append((name, f"c_uint8 * {member.bytes}")) + elif isinstance(member, ListField): + # At this stage, we don't need to prepare the libxcb list accessor initializers. We'll do that when + # we emit the Python wrappers. + seen_list = True + else: + msg = f"Struct {entry.protocol}:{entry.name} has unrecognized member {member}" + raise GenerationError(msg) + assert bool(entry.lists) == seen_list # noqa: S101 + + writer.write("_fields_ = (") + with writer.indent(): + for name, type_expr in field_entries: + writer.write(f'("{name}", {type_expr}),') + writer.write(")") + + if seen_list and not isinstance(entry, ReplyDefn): + # This is a variable-length structure, and it's presumably being accessed from a containing structure's list. + # It'll need an iterator type in Python, and has a foo_next function in libxcb. + # + # We don't try to determine if it's actually being accessed by a containing structure's list: the only way we + # get here without a parent structure is if this is in TYPES. But libxcb still defines the iterator and + # xcb_setup_next function, so we don't have to track if that's happened. + iterator_name = f"{class_name}Iterator" + writer.write() + writer.write(f"class {iterator_name}(Structure):") + with writer.indent(): + writer.write('_fields_ = (("data", POINTER(' + class_name + ')), ("rem", c_int), ("index", c_int))') + next_func_name = f"xcb_{format_function_name(entry.name, entry.protocol)}_next" + rv.append(FuncDecl(entry.protocol, next_func_name, [f"POINTER({iterator_name})"], "None")) + + return rv + + +def emit_reply(writer: CodeWriter, registry: ProtocolRegistry, entry: ReplyDefn) -> list[FuncDecl]: + # Replies have a generic structure at the beginning that isn't in the XML spec: + # uint8_t response_type; + # uint8_t pad0; + # uint16_t sequence; + # uint32_t length; + # However, if the first field of the reply contents is a single byte, then it replaces pad0 in that structure. + nonfd_members = [m for m in entry.members if not isinstance(m, FdField)] + + field_entries: list[tuple[str, str] | StructMember] = [("response_type", "c_uint8")] + if ( + nonfd_members + and isinstance(nonfd_members[0], Field) + and is_eight_bit(registry, entry.protocol, nonfd_members[0].type) + ): + member = nonfd_members.pop(0) + assert isinstance(member, Field) # noqa: S101 + name = format_field_name(member) + type_expr = python_type_for(registry, entry.protocol, member.type) + field_entries.append((name, type_expr)) + elif nonfd_members and (isinstance(nonfd_members[0], Pad) and nonfd_members[0].bytes == 1): + # XFixes puts the padding byte explicitly at the start of the replies, but it just gets folded in the same way. + member = nonfd_members.pop(0) + field_entries.append(member) + else: + field_entries.append(Pad(bytes=1)) + field_entries.append(("sequence", "c_uint16")) + field_entries.append(("length", "c_uint32")) + field_entries += nonfd_members + + return emit_structlike(writer, registry, entry, field_entries) + + +# Types + + +def emit_types( + writer: CodeWriter, + registry: ProtocolRegistry, + types: list[TypeDefn], +) -> list[FuncDecl]: + rv: list[FuncDecl] = [] + writer.write() + writer.write("# Generated ctypes structures") + + xid_derived_from: dict[tuple[str, str], XidUnionDefn] = {} + # We have to emit the unions first, so that we can emit the types that can comprise them as subtypes. + for union in types: + if not isinstance(union, XidUnionDefn): + continue + emit_xidunion(writer, registry, union) + for subtype_name in union.types: + subtype = registry.resolve_type(union.protocol, subtype_name) + subtype_key = (subtype.protocol, subtype.name) + if subtype_key in xid_derived_from: + # We could probably use multiple inheritance, but I don't have a test case. + msg = ( + f"XID {subtype.protocol}.{subtype.name} is used in multiple unions. This is" + "not currently supported." + ) + raise GenerationError(msg) + xid_derived_from[subtype_key] = union + + for typ in types: + with parsing_note(f"while emitting type {typ.protocol}:{typ.name}"): + if isinstance(typ, XidTypeDefn): + emit_xid(writer, registry, typ, xid_derived_from.get((typ.protocol, typ.name))) + elif isinstance(typ, XidUnionDefn): + pass + elif isinstance(typ, TypedefDefn): + emit_typedef(writer, registry, typ) + elif isinstance(typ, StructDefn): + rv += emit_structlike(writer, registry, typ) + elif isinstance(typ, ReplyDefn): + rv += emit_reply(writer, registry, typ) + else: + msg = f"Unsupported type kind {type(typ).__name__} for {typ.protocol}:{typ.name}" + raise GenerationError(msg) + return rv + + +# List wrappers + + +def emit_list_field( + writer: CodeWriter, registry: ProtocolRegistry, struct: StructLikeDefn, field: ListField +) -> list[FuncDecl]: + protocol = struct.protocol + base_func_name = f"{format_function_name(struct.name, protocol)}_{format_function_name(field.name)}" + outer_type_name = format_type_name(struct) + if field.type == "void": + # This means that we're getting a void*. Use an Array[c_char] instead of a c_void_p, so we have the length + # information. + inner_is_variable = False + inner_type_name = "c_char" + elif field.type in PRIMITIVE_CTYPES: + inner_is_variable = False + inner_type_name = PRIMITIVE_CTYPES[field.type] + else: + inner_type = registry.resolve_type(protocol, field.type) + inner_is_variable = type_is_variable(inner_type) + inner_type_name = format_type_name(inner_type) + lib = lib_for_proto(protocol) + if inner_is_variable: + iterator_type_name = f"{inner_type_name}Iterator" + xcb_iterator_func_name = f"xcb_{base_func_name}_iterator" + xcb_next_func_name = f"xcb_{format_function_name(field.type, struct.protocol)}_next" + writer.write() + writer.write(f"def {base_func_name}(r: {outer_type_name}) -> list[{inner_type_name}]:") + with writer.indent(): + writer.write(f"return list_from_xcb(LIB.{lib}.{xcb_iterator_func_name}, LIB.{lib}.{xcb_next_func_name}, r)") + return [ + FuncDecl(lib, xcb_iterator_func_name, [f"POINTER({outer_type_name})"], iterator_type_name), + # The "next" function was defined alongside the iterator type. + ] + xcb_array_pointer_func_name = f"xcb_{base_func_name}" + xcb_array_length_func_name = f"xcb_{base_func_name}_length" + writer.write() + writer.write(f"def {base_func_name}(r: {outer_type_name}) -> Array[{inner_type_name}]:") + with writer.indent(): + writer.write( + f"return array_from_xcb(LIB.{lib}.{xcb_array_pointer_func_name}, LIB.{lib}.{xcb_array_length_func_name}, r)" + ) + return [ + FuncDecl(lib, xcb_array_pointer_func_name, [f"POINTER({outer_type_name})"], f"POINTER({inner_type_name})"), + FuncDecl(lib, xcb_array_length_func_name, [f"POINTER({outer_type_name})"], XCB_LENGTH_TYPE), + ] + + +def emit_lists(writer: CodeWriter, registry: ProtocolRegistry, types: list[TypeDefn]) -> list[FuncDecl]: + rv: list[FuncDecl] = [] + for typ in types: + if not isinstance(typ, StructLikeDefn): + continue + for list_field in typ.lists: + rv += emit_list_field(writer, registry, typ, list_field) + return rv + + +# File descriptor accessor wrappers + + +def emit_fds(writer: CodeWriter, _registry: ProtocolRegistry, types: list[TypeDefn]) -> list[FuncDecl]: + rv: list[FuncDecl] = [] + for typ in types: + if not isinstance(typ, StructLikeDefn): + continue + fd_members = [m for m in typ.members if isinstance(m, FdField)] + if not fd_members: + continue + if len(fd_members) > 1: + # I simply don't know how this would be represented in libxcb. + msg = f"Struct {typ.protocol}:{typ.name} has multiple FdFields, which is unsupported" + raise GenerationError(msg) + # The way that the reply fd accessor is named is not that great: + # rather than having a function named after the field, it's named with just an "_fd" suffix. + func_name = f"{format_function_name(typ.name, typ.protocol)}_reply_fds" + writer.write() + writer.write( + f"def {func_name}(c: Connection | _Pointer[Connection], r: {format_type_name(typ)}) -> _Pointer[c_int]:" + ) + with writer.indent(): + writer.write(f"return LIB.{lib_for_proto(typ.protocol)}.xcb_{func_name}(c, r)") + rv.append( + FuncDecl( + typ.protocol, + f"xcb_{func_name}", + ["POINTER(Connection)", f"POINTER({format_type_name(typ)})"], + "POINTER(c_int)", + ) + ) + return rv + + +# Request wrapper functions + + +def emit_requests(writer: CodeWriter, registry: ProtocolRegistry, requests: list[RequestDefn]) -> list[FuncDecl | str]: + rv: list[FuncDecl | str] = [] + for request in requests: + lib = lib_for_proto(request.protocol) + func_name = format_function_name(request.name, request.protocol) + xcb_func_name = f"xcb_{func_name}" + if request.lists: + msg = "Cannot handle requests with lists at present" + raise GenerationError(msg) + # Parameters are the inputs you declare in the function's "def" line. Arguments are the inputs you provide to + # a function when you call it. + params: list[tuple[str, str]] = [("c", "Connection")] + params += [ + ( + format_field_name(field), + python_type_for(registry, request.protocol, field.type) if isinstance(field, Field) else "c_int", + ) + for field in request.members + if not isinstance(field, (Pad, ListField)) + ] + params_types = [p[1] for p in params] + # Arrange for the wrappers to take Python ints in place of any of the int-based ctypes. + params_with_alts = [(p[0], f"{p[1]} | int" if p[1] in INT_CTYPES else p[1]) for p in params] + params_string = ", ".join(f"{p[0]}: {p[1]}" for p in params_with_alts) + args_string = ", ".join(p[0] for p in params) + xcb_params_types = ["POINTER(Connection)", *params_types[1:]] + if request.reply is None: + xcb_func_name += "_checked" + writer.write() + writer.write(f"def {func_name}({params_string}) -> None:") + with writer.indent(): + writer.write(f"return LIB.{lib}.{xcb_func_name}({args_string}).check(c)") + rv.append(FuncDecl(request.protocol, xcb_func_name, xcb_params_types, "VoidCookie")) + else: + reply_type = request.reply + reply_type_name = format_type_name(reply_type) + writer.write() + writer.write(f"def {func_name}({params_string}) -> {reply_type_name}:") + with writer.indent(): + writer.write(f"return LIB.{lib}.{xcb_func_name}({args_string}).reply(c)") + # We have to use initialize_xcb_typed_func to initialize late, rather than making the cookie class here, + # because the cookie definition needs to reference the XCB reply function. We could also do a lazy + # initialization, but it's probably not worth it. + rv.append( + f'initialize_xcb_typed_func(LIB.{lib}, "{xcb_func_name}", ' + f"[{', '.join(xcb_params_types)}], {reply_type_name})" + ) + return rv + + +# Initializer function + + +def emit_initialize(writer: CodeWriter, func_decls: list[FuncDecl | str]) -> None: + writer.write() + writer.write("def initialize() -> None: # noqa: PLR0915") + with writer.indent(): + for decl in func_decls: + if isinstance(decl, str): + writer.write(decl) + else: + lib = lib_for_proto(decl.protocol) + writer.write(f"LIB.{lib}.{decl.name}.argtypes = ({', '.join(decl.argtypes)},)") + writer.write(f"LIB.{lib}.{decl.name}.restype = {decl.restype}") + + +# Top level code generator + + +def generate( + output: io.TextIOBase, + proto_dir: Path, + type_requirements: Mapping[str, list[str]] | None = None, + request_requirements: Mapping[str, list[str]] | None = None, +) -> None: + registry = ProtocolRegistry(proto_dir) + type_requirements = type_requirements or TYPES + request_requirements = request_requirements or REQUESTS + plan = toposort_requirements(registry, type_requirements, request_requirements) + + func_decls: list[FuncDecl | str] = [] + + writer = CodeWriter(output) + writer.write(GENERATED_HEADER.rstrip()) + emit_versions(writer, registry) + emit_enums(writer, registry, plan.enums) + func_decls += emit_types(writer, registry, plan.types) + func_decls += emit_lists(writer, registry, plan.types) + func_decls += emit_fds(writer, registry, plan.types) + func_decls += emit_requests(writer, registry, plan.requests) + emit_initialize(writer, func_decls) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate ctypes bindings from XCB protocol XML") + parser.add_argument("--proto_dir", type=Path, default=Path(__file__).resolve().parent) + parser.add_argument("--output_path", type=Path, default=Path("xcbgen.py")) + args = parser.parse_args(argv) + with args.output_path.open("w") as fh: + generate(fh, args.proto_dir, TYPES, REQUESTS) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/xcbproto/randr.xml b/src/xcbproto/randr.xml new file mode 100644 index 00000000..64fa2d44 --- /dev/null +++ b/src/xcbproto/randr.xml @@ -0,0 +1,954 @@ + + + + + + xproto + render + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + nRates + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + 1 + 2 + 3 + + 4 + 5 + 6 + 7 + + + + + + + + + + + + + + + + + + + + + + + + nSizes + + + + nInfo + nSizes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_crtcs + + + num_outputs + + + num_modes + + + + names_len + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + num_crtcs + + + num_modes + + + num_clones + + + name_len + + + + + + + + + + + + num_atoms + + + + + + + + + + + + + + + length + + + + + + + + + + + + + + + + + + + + + + + + + num_units + format + + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_items + + format + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_outputs + + + num_possible_outputs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + size + + + size + + + size + + + + + + + + + + size + + + size + + + size + + + + + + + + + + + + + + + + + + num_crtcs + + + num_outputs + + + num_modes + + + + names_len + + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + filter_len + + + + + + + + + + + + + + + + + + + + pending_len + + + + pending_nparams + + + current_len + + + + current_nparams + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_providers + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + + num_crtcs + + + num_outputs + + + num_associated_providers + + + num_associated_providers + + + name_len + + + + + + + + + + + + + + + + + + + + + + + + + num_atoms + + + + + + + + + + + + + + + length + + + + + + + + + + + + + + + + + + + + + + + + num_items + + format + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_items + + format + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nOutput + + + + + + + + + + + + + + nMonitors + + + + + + + + + + + + + + + + + + + + + + + num_crtcs + + + num_outputs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/xcbproto/render.xml b/src/xcbproto/render.xml new file mode 100644 index 00000000..7bee25ec --- /dev/null +++ b/src/xcbproto/render.xml @@ -0,0 +1,693 @@ + + + + + + xproto + + + 0 + 1 + + + + 0 + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + 25 + 26 + 27 + + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + + + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + + + + 0 + 1 + + + + 0 + 1 + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_visuals + + + + + + + + num_depths + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_formats + + + num_screens + + + num_subpixel + + + + + + + + + + + + + num_values + + + + + + + + + + + + + value_mask + + Repeat + + + + AlphaMap + + + + AlphaXOrigin + + + + AlphaYOrigin + + + + ClipXOrigin + + + + ClipYOrigin + + + + ClipMask + + + + GraphicsExposure + + + + SubwindowMode + + + + PolyEdge + + + + PolyMode + + + + Dither + + + + ComponentAlpha + + + + + + + + + + value_mask + + Repeat + + + + AlphaMap + + + + AlphaXOrigin + + + + AlphaYOrigin + + + + ClipXOrigin + + + + ClipYOrigin + + + + ClipMask + + + + GraphicsExposure + + + + SubwindowMode + + + + PolyEdge + + + + PolyMode + + + + Dither + + + + ComponentAlpha + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + glyphs_len + + + glyphs_len + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_aliases + + + num_filters + + + + + + + + + + filter_len + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_stops + + + num_stops + + + + + + + + + + + + num_stops + + + num_stops + + + + + + + + + + num_stops + + + num_stops + + + diff --git a/src/xcbproto/shm.xml b/src/xcbproto/shm.xml new file mode 100644 index 00000000..c1114e0a --- /dev/null +++ b/src/xcbproto/shm.xml @@ -0,0 +1,350 @@ + + + + xproto + + + + + + + + + + + + + Report that an XCB_SHM_PUT_IMAGE request has completed + + + + + + + + + + + + + + + + + + + + + + The version of the MIT-SHM extension supported by the server + + + + + The UID of the server. + The GID of the server. + + + + Query the version of the MIT-SHM extension. + + + + + + + + + + + Attach a System V shared memory segment. + + + + + + + + + + + Destroys the specified shared memory segment. + + The segment to be destroyed. + + + + + + + + + + + + + + + + + + + + + + Copy data from the shared memory to the specified drawable. + + The drawable to draw to. + The graphics context to use. + The total width of the source image. + The total height of the source image. + The source X coordinate of the sub-image to copy. + The source Y coordinate of the sub-image to copy. + + + + + The depth to use. + + + The offset that the source image starts at. + + + + + + + + + + + + + + + + + + + + + Indicates the result of the copy. + + The depth of the source drawable. + The visual ID of the source drawable. + The number of bytes copied. + + + + Copies data from the specified drawable to the shared memory segment. + + The drawable to copy the image out of. + The X coordinate in the drawable to begin copying at. + The Y coordinate in the drawable to begin copying at. + The width of the image to copy. + The height of the image to copy. + A mask that determines which planes are used. + The format to use for the copy (???). + The destination shared memory segment. + The offset in the shared memory segment to copy data to. + + + + + + + + + + + + + + Create a pixmap backed by shared memory. + +Create a pixmap backed by shared memory. Writes to the shared memory will be +reflected in the contents of the pixmap, and writes to the pixmap will be +reflected in the contents of the shared memory. + + A pixmap ID created with xcb_generate_id(). + The drawable to create the pixmap in. + + + + + + + + + + + + + + + Create a shared memory segment + + + The file descriptor the server should mmap(). + + + + + + + + + + + + + + + The returned file descriptor. + + + + + + Asks the server to allocate a shared memory segment. + + + The size of the segment to create. + + + + diff --git a/src/xcbproto/xfixes.xml b/src/xcbproto/xfixes.xml new file mode 100644 index 00000000..5e54c420 --- /dev/null +++ b/src/xcbproto/xfixes.xml @@ -0,0 +1,405 @@ + + + + + xproto + render + shape + + + + + + + + + + + + + + + 0 + 1 + + + + 0 + 1 + + + + 0 + 1 + + + + + + + + + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + 0 + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + width + height + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + length + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nbytes + + + + + + + + + + nbytes + + + + + + + + + + + + + + + + + + + width + height + + + nbytes + + + + + + + + + + + + + nbytes + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + num_devices + + + + + + + + + + 0 + 0 + + + + + + + + + + Sets the disconnect mode for the client. + + + + + + + + + + + Gets the disconnect mode for the client. + + + + + diff --git a/src/xcbproto/xproto.xml b/src/xcbproto/xproto.xml new file mode 100644 index 00000000..9a0245a4 --- /dev/null +++ b/src/xcbproto/xproto.xml @@ -0,0 +1,5637 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + WINDOW + PIXMAP + + + + FONT + GCONTEXT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + visuals_len + + + + + 0 + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + allowed_depths_len + + + + + + + + + + + + + authorization_protocol_name_len + + + + authorization_protocol_data_len + + + + + + + + + + + + reason_len + + + + + + + + + + length + 4 + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + vendor_len + + + + pixmap_formats_len + + + roots_len + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 15 + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + + + + 0 + + + + + + + + + + + + + + + + + a key was pressed/released + + + + + + + + + + + + + + + + + + 8 + 9 + 10 + 11 + 12 + 15 + + + + + + + + + + + + + + + + + a mouse button was pressed/released + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + a key was pressed + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + + + the pointer is in a different window + + + + + + + + + + + + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + + 31 + + + + + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + a window is destroyed + + + + + + + + + + + + + + a window is unmapped + + + + + + + + + + + + + + + a window was mapped + + + + + + + + + + + + + window wants to be mapped + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + a window property changed + + + + + + + + + + + + + + + + + + 0 + + + + 0 + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + 25 + 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + 0 + + + + + + + + + + + the colormap for some window changed + + + + + + + + + + + + 20 + 10 + 5 + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + keyboard mapping changed + + + + + + + + + + + generic event (with length) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + 0 + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + + + + + + + + + + + + + + + + value_mask + + BackPixmap + + + + BackPixel + + + + BorderPixmap + + + + BorderPixel + + + + BitGravity + + + + WinGravity + + + + BackingStore + + + + BackingPlanes + + + + BackingPixel + + + + OverrideRedirect + + + + SaveUnder + + + + EventMask + + + + DontPropagate + + + + Colormap + + + + Cursor + + + + + + Creates a window + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + value_mask + + BackPixmap + + + + BackPixel + + + + BorderPixmap + + + + BorderPixel + + + + BitGravity + + + + WinGravity + + + + BackingStore + + + + BackingPlanes + + + + BackingPixel + + + + OverrideRedirect + + + + SaveUnder + + + + EventMask + + + + DontPropagate + + + + Colormap + + + + Cursor + + + + + + change window attributes + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gets window attributes + + + + + + + + + + + + + Destroys a window + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + Changes a client's save set + + + + + + + + + + + + + + + + + + Reparents a window + + + + + + + + + + + + + + + + + + Makes a window visible + + + + + + + + + + + + + + + + + + + Makes a window invisible + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + + + + 0 + 1 + 2 + 3 + 4 + + + + + + + + + value_mask + + X + + + + Y + + + + Width + + + + Height + + + + BorderWidth + + + + Sibling + + + + StackMode + + + + + + Configures window attributes + + + + + + + + + + + + + + + 0 + 1 + + + + + + + Change window stacking order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Get current window geometry + + x, reply->y); + } + free(reply); +} + ]]> + + + + + + + + + + + + + + + + + + children_len + + + + + + + + + query the window tree + + root); + printf("parent = 0x%08x\\n", reply->parent); + + xcb_window_t *children = xcb_query_tree_children(reply); + for (int i = 0; i < xcb_query_tree_children_length(reply); i++) + printf("child window = 0x%08x\\n", children[i]); + + free(reply); + } +} + ]]> + + + + + + + + + + + name_len + + + + + + + + Get atom identifier by name + + atom); + free(reply); + } +} + ]]> + + + + + + + + + + + + + + + + + + + + name_len + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + data_len + format + + 8 + + + + Changes a window property + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + value_len + + format + 8 + + + + + + + + + + + + + Gets a window property + + + + + + + + + + + + + + + + + + + + + + + + + atoms_len + + + + + + + + + + + Sets the owner of a selection + + + + + + + + + + + + + + + + + + + + + + + Gets the owner of a selection + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + 32 + + send an event + + event = window; + event->window = window; + event->response_type = XCB_CONFIGURE_NOTIFY; + + event->x = 0; + event->y = 0; + event->width = 800; + event->height = 600; + + event->border_width = 0; + event->above_sibling = XCB_NONE; + event->override_redirect = false; + + xcb_send_event(conn, false, window, XCB_EVENT_MASK_STRUCTURE_NOTIFY, + (char*)event); + xcb_flush(conn); + free(event); +} + ]]> + + + + + + + + + + + + 0 + 1 + + + + + + + + 0 + 1 + 2 + 3 + 4 + + + + 0 + + + + + + + + + + + + + + + + + Grab the pointer + + root, /* grab the root window */ + XCB_NONE, /* which events to let through */ + XCB_GRAB_MODE_ASYNC, /* pointer events should continue as normal */ + XCB_GRAB_MODE_ASYNC, /* keyboard mode */ + XCB_NONE, /* confine_to = in which window should the cursor stay */ + cursor, /* we change the cursor to whatever the user wanted */ + XCB_CURRENT_TIME + ); + + if ((reply = xcb_grab_pointer_reply(conn, cookie, NULL))) { + if (reply->status == XCB_GRAB_STATUS_SUCCESS) + printf("successfully grabbed the pointer\\n"); + free(reply); + } +} + ]]> + + + + + + + + + + + + + + + + + + + + + release the pointer + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + + + + Grab pointer button(s) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Grab the keyboard + + root, /* grab the root window */ + XCB_CURRENT_TIME, + XCB_GRAB_MODE_ASYNC, /* process events as normal, do not require sync */ + XCB_GRAB_MODE_ASYNC + ); + + if ((reply = xcb_grab_keyboard_reply(conn, cookie, NULL))) { + if (reply->status == XCB_GRAB_STATUS_SUCCESS) + printf("successfully grabbed the keyboard\\n"); + + free(reply); + } +} + ]]> + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Grab keyboard key(s) + + + + + + + + + + + + + + + + + + + + + + release a key combination + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + + + + + + + + + + + + + + + + release queued events + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + get pointer coordinates + + + + + + + + + + + + + + + + + + + + + + + events_len + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + move mouse pointer + + + + + + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + Sets input focus + + + + + + + + + + + + + + + + + + + + + + + + + 32 + + + + + + + + + + name_len + + + opens a font + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + properties_len + + + char_infos_len + + + + + + + + + + + + + + + + + query font metrics + + + + + + + + + string_len1 + + + + + + + + + + + + + + + + get text extents + + + + + + + + + + + + + name_len + + + + + + + + + pattern_len + + + + + + + names_len + + + + + + + + get matching font names + + + + + + + + + + + + + pattern_len + + + + + + + + + + + + + + + + + + + + properties_len + + + name_len + + + + + + + + + + + + + + + + + + + get matching font names and information + + + + + + + + + + + + + + font_qty + + + + + + + + + + path_len + + + + + + + + + + + + + Creates a pixmap + + + + + + + + + + + + + + + + + + Destroys a pixmap + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + 3 + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + 3 + + + + 0 + 1 + + + + 0 + 1 + + + + 0 + 1 + + + + + + + + + value_mask + + Function + + + + PlaneMask + + + + Foreground + + + + Background + + + + LineWidth + + + + LineStyle + + + + CapStyle + + + + JoinStyle + + + + FillStyle + + + + FillRule + + + + Tile + + + + Stipple + + + + TileStippleOriginX + + + + TileStippleOriginY + + + + Font + + + + SubwindowMode + + + + GraphicsExposures + + + + ClipOriginX + + + + ClipOriginY + + + + ClipMask + + + + DashOffset + + + + DashList + + + + ArcMode + + + + + Creates a graphics context + + + + + + + + + + + + + + + + + + + value_mask + + Function + + + + PlaneMask + + + + Foreground + + + + Background + + + + LineWidth + + + + LineStyle + + + + CapStyle + + + + JoinStyle + + + + FillStyle + + + + FillRule + + + + Tile + + + + Stipple + + + + TileStippleOriginX + + + + TileStippleOriginY + + + + Font + + + + SubwindowMode + + + + GraphicsExposures + + + + ClipOriginX + + + + ClipOriginY + + + + ClipMask + + + + DashOffset + + + + DashList + + + + ArcMode + + + + + change graphics context components + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dashes_len + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + Destroys a graphics context + + + + + + + + + + + + + + + + + + + + + + + + + + + + copy areas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + + + + draw lines + + + + + + + + + + + + + + + + + + + + + + + + + + + + + draw lines + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + Fills rectangles + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + length + 4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + string_len + + + Draws text + + + + + + + + + + + + + + + + + + + + + + string_len + + + Draws text + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cmaps_len + + + + + + + + + + + + + + + + + + + + + + Allocate a color + + + + + + + + + + + + + + + + name_len + + + + + + + + + + + + + + + + + + + + + + + + + + pixels_len + + + masks_len + + + + + + + + + + + + + + + + + + + + + pixels_len + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + name_len + + + + + + + + + + + + + + + + + + + + colors_len + + + + + + + + + + + name_len + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + create cursor + + + + + + + + + + + + + + + + + + + + + + + + Deletes a cursor + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + name_len + + + + + + + + + + + + + + + + + check if extension is present + + + + + + + + + + + + + + names_len + + + + + + + + + + + + + keycode_count + keysyms_per_keycode + + + + + + + + + + + + + length + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + + + 0 + 1 + + + + 0 + 1 + 2 + + + + + + + value_mask + + KeyClickPercent + + + + BellPercent + + + + BellPitch + + + + BellDuration + + + + Led + + + + LedMode + + + + Key + + + + AutoRepeatMode + + + + + + + + + + + + + + + + 32 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + 0 + 1 + 2 + 5 + 6 + + + + + + + + + address_len + + + + + + + + + address_len + + + + + + + + + + + hosts_len + + + + + + 0 + 1 + + + + + + + + 0 + 1 + 2 + + + + + + + + 0 + + + + + + + kills a client + + + + + + + + + + + + + + + atoms_len + + + + + 0 + 1 + + + + + + + + + 0 + 1 + 2 + + + + + + map_len + + + + + + + + + + + + + map_len + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + + + + + + keycodes_per_modifier + 8 + + + + + + + + + + + + + + + keycodes_per_modifier + 8 + + + + + + + + +