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..f5c8e790 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,12 +1,3 @@ -# 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 +polar: tiger-222 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..b2bd4a25 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,5 +11,4 @@ It is **very** important to keep up to date tests and documentation. Is your code right? -- [ ] PEP8 compliant -- [ ] `flake8` passed +- [ ] `./check.sh` passed diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8d9e0b26 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + # GitHub Actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: + - dependencies + - QA/CI + + # Python requirements + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + assignees: + - BoboTiG + labels: + - dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1a99a0c7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release + +on: + push: + tags: + - '*' + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + cache: pip + - name: Install build dependencies + run: | + python -m pip install -U pip + python -m pip install -e '.[dev]' + - name: Build + run: python -m build + - name: Check + run: twine check --strict dist/* + - name: What will we publish? + run: ls -l dist + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + skip_existing: true + print_hash: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..547d4a28 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,103 @@ +name: Tests + +on: + 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@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + cache: pip + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -e '.[dev]' + - name: Check + run: ./check.sh + + documentation: + name: Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + cache: pip + - name: Install dependencies + run: | + 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 + + tests: + name: "${{ matrix.os.emoji }} ${{ matrix.python.name }}" + runs-on: ${{ matrix.os.runs-on }} + strategy: + fail-fast: false + matrix: + os: + - emoji: 🐧 + runs-on: [ubuntu-latest] + - emoji: 🍎 + runs-on: [macos-latest] + - emoji: 🪟 + runs-on: [windows-latest] + python: + - name: CPython 3.9 + runs-on: "3.9" + - name: CPython 3.10 + runs-on: "3.10" + - name: CPython 3.11 + runs-on: "3.11" + - name: CPython 3.12 + 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@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python.runs-on }} + cache: pip + check-latest: true + - name: Install dependencies + run: | + 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 e9437f3a..79426812 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,24 @@ -build/ -.cache/ -dist/ -*.egg-info/ -.idea/ +# Files +.coverage +*.doctree .DS_Store *.orig *.jpg -*.png +/*.png *.png.old +*.pickle *.pyc -.pytest_cache -.tox -.vscode + +# Folders +build/ +.cache/ +dist/ +docs_out/ +*.egg-info/ +.idea/ +.pytest_cache/ docs/output/ .mypy_cache/ +__pycache__/ +ruff_cache/ venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index c7f49431..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -fail_fast: true - -repos: -- repo: https://github.com/ambv/black - rev: stable - hooks: - - id: black -- repo: https://gitlab.com/pycqa/flake8 - rev: master - hooks: - - id: flake8 -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: master - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-docstring-first - - id: debug-statements - - id: check-ast - - id: no-commit-to-branch diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 07fee3fc..00000000 --- a/.pylintrc +++ /dev/null @@ -1,6 +0,0 @@ -[MESSAGES CONTROL] -disable = locally-disabled, too-few-public-methods, too-many-instance-attributes, duplicate-code - -[REPORTS] -output-format = colorized -reports = no 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/.travis.yml b/.travis.yml deleted file mode 100644 index 0297694e..00000000 --- a/.travis.yml +++ /dev/null @@ -1,125 +0,0 @@ -# Beta opt-in -# https://docs.travis-ci.com/user/build-config-validation#beta-opt-in -version: ~> 1.0 - -language: python -dist: xenial -os: linux - -env: - global: - - MAKEFLAGS="-j 2" - -jobs: - fast_finish: true - include: - - name: Code quality checks - python: "3.8" - env: TOXENV=lint - - name: Types checking - python: "3.8" - env: TOXENV=types - - name: Documentation build - python: "3.8" - env: TOXENV=docs - - name: Python 3.5 on macOS - os: osx - language: shell - install: - - unset PYENV_ROOT - - curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash - - export PATH="$HOME/.pyenv/bin:$PATH" - - eval "$(pyenv init -)" - - pyenv install --skip-existing 3.5.10 - - pyenv global 3.5.10 - env: TOXENV=py35 - - name: Python 3.6 on macOS - os: osx - language: shell - install: - - unset PYENV_ROOT - - curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash - - export PATH="$HOME/.pyenv/bin:$PATH" - - eval "$(pyenv init -)" - - pyenv install --skip-existing 3.6.12 - - pyenv global system 3.6.12 - env: TOXENV=py36 - - name: Python 3.7 on macOS - os: osx - language: shell - install: - - unset PYENV_ROOT - - curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash - - export PATH="$HOME/.pyenv/bin:$PATH" - - eval "$(pyenv init -)" - - pyenv install --skip-existing 3.7.9 - - pyenv global system 3.7.9 - env: TOXENV=py37 - - name: Python 3.8 on macOS - os: osx - language: shell - install: - - unset PYENV_ROOT - - curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash - - export PATH="$HOME/.pyenv/bin:$PATH" - - eval "$(pyenv init -)" - - pyenv install --skip-existing 3.8.6 - - pyenv global system 3.8.6 - env: TOXENV=py38 - - name: Python 3.9 on macOS - os: osx - language: shell - install: - - unset PYENV_ROOT - - curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash - - export PATH="$HOME/.pyenv/bin:$PATH" - - eval "$(pyenv init -)" - - pyenv install --skip-existing 3.9-dev - - pyenv global system 3.9-dev - env: TOXENV=py39 - - name: Python 3.10 on macOS - os: osx - language: shell - install: - - unset PYENV_ROOT - - curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash - - export PATH="$HOME/.pyenv/bin:$PATH" - - eval "$(pyenv init -)" - - pyenv install --skip-existing 3.10-dev - - pyenv global system 3.10-dev - env: TOXENV=py310 - - name: PyPy 3.6 on GNU/Linux - python: pypy3 - env: TOXENV=pypy3 - - name: Python 3.5 on GNU/Linux - python: "3.5" - env: TOXENV=py35 - - name: Python 3.6 on GNU/Linux - python: "3.6" - env: TOXENV=py36 - - name: Python 3.7 on GNU/Linux - python: "3.7" - env: TOXENV=py37 - - name: Python 3.8 on GNU/Linux - python: "3.8" - env: TOXENV=py38 - - name: Python 3.9 on GNU/Linux - python: 3.9-dev - env: TOXENV=py39 - - name: Python 3.10 on GNU/Linux - python: nightly - env: TOXENV=py310 - -addons: - apt: - packages: - - lsof - -services: - - xvfb - -before_script: - - python3 -m pip install --upgrade pip tox - -script: - - python3 -m tox 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 b/CHANGELOG deleted file mode 100644 index d84a392d..00000000 --- a/CHANGELOG +++ /dev/null @@ -1,229 +0,0 @@ -History: - - - -6.1.0 2020/10/31 - - MSS: reworked how C functions are initialised - - 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: fix macOS intepreter not found on Travis-CI - - tests: fix test_entry_point() when there are several monitors - -6.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) - - 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 - - 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 - - tests: fix tests on macOS with Retina display - - Windows: fixed multi-thread safety (fixes #150) - - :heart: contributors: @narumishi - -5.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) - - 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 - - new contributor: foone - - Windows: ignore missing SetProcessDPIAware() on Window XP (fixes #109) - -4.0.1 2019/01/26 - - Linux: fix several XLib functions signature (fixes #92) - - Linux: improve monitors finding by a factor of 44 - -4.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: fix Flake8 C408: Unnecessary dict call - rewrite as a literal, in exceptions.py - - MSS: fix Flake8 I100: Import statements are in the wrong order - - MSS: fix Flake8 I201: Missing newline before sections or imports - - MSS: fix PyLint bad-super-call: Bad first argument 'Exception' given to super() - - tests: use tox, enable PyPy and PyPy3, add macOS and Windows CI - -3.3.2 2018/11/20 - - new contributors: hugovk, Andreas Buhr - - MSS: do monitor detection in MSS constructor (fixes #79) - - MSS: specify compliant Python versions for pip install - - tests: enable Python 3.7 - - tests: fix test_entry_point() with multiple monitors - -3.3.1 2018/09/22 - - Linux: fix a memory leak introduced with 7e8ae5703f0669f40532c2be917df4328bc3985e (fixes #72) - - doc: add the download statistics badge - -3.3.0 2018/09/04 - - Linux: add an error handler for the XServer to prevent interpreter crash (fix #61) - - MSS: fix a ResourceWarning: unclosed file in setup.py - - tests: fix a ResourceWarning: unclosed file - - doc: fix a typo in Screenshot.pixel() method (thanks to @mchlnix) - - big code clean-up using black - -3.2.1 2018/05/21 - - new contributor: Ryan Fox - - Windows: enable Hi-DPI awareness - -3.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 - -3.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 (fix #37) - - CI: build the documentation - -3.1.1 2017/11/27 - - MSS: add the 'mss' entry point - -3.1.0 2017/11/16 - - new contributor: Karan Lyons - - MSS: add more way of customization to the output argument of save() - - MSS: possibility to use custom class to handle screen shot data - - Mac: properly support all display scaling and resolutions (fix #14, #19, #21, #23) - - Mac: fix memory leaks (fix #24) - - Linux: handle bad display value - - Windows: take into account zoom factor for high-DPI displays (fix #20) - - doc: several fixes (fix #22) - - tests: a lot of tests added for better coverage - - add the 'Say Thanks' button - -3.0.1 2017/07/06 - - fix examples links - -3.0.0 2017/07/06 - - big refactor, introducing the ScreenShot class - - MSS: add Numpy array interface support to the Screenshot class - - docs: add OpenCV/Numpy, PIL pixels, FPS - -2.0.22 2017/04/29 - - new contributors: David Becker, redodo - - MSS: better use of exception mechanism - - 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 (fix #14) - - docs: add an example to capture only a part of the screen - -2.0.18 2016/12/03 - - change license to MIT - - new contributor: Jochen 'cycomanic' Schroeder - - MSS: add type hints - - MSS: remove unused code (reported by Vulture) - - Linux: remove MSS library - - Linux: insanely fast using only ctypes - - Linux: skip unused monitors - - Linux: use errcheck instead of deprecated restype with callable (fix #11) - - Linux: fix security issue (reported by Bandit) - - docs: add documentation (fix #10) - - tests: add tests and use Travis CI (fix #9) - -2.0.0 2016/06/04 - - split the module into several files - - MSS: a lot of code refactor and optimizations - - MSS: rename save_img() to to_png() - - MSS: save(): replace 'screen' argument by 'mon' - - Mac: get rid of the PyObjc module, 100% ctypes - - 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: fix #6, screen shot not correct on Windows 8 - - add issue and pull request templates - -1.0.2 2016/04/22 - - MSS: fix non existent alias - -1.0.1 2016/04/22 - - MSS: fix #7, libpng warning (ignoring bad filter type) - -1.0.0 2015/04/16 - - Python 2.6 to 3.5 ready - - MSS: code purgation and review, no more debug information - - MSS: fix #5, add a shortcut to take automatically use the proper MSS class - - MSS: few optimizations into save_img() - - Darwin: remove rotation from information returned by enum_display_monitors() - - Linux: fix object has no attribute 'display' into __del__ - - Linux: use of XDestroyImage() instead of XFree() - - Linux: optimizations of get_pixels() - - Windows: huge optimization of get_pixels() - - CLI: delete --debug argument - -0.1.1 2015/04/10 - - MSS: little code review - - Linux: fix monitor count - - tests: remove test-linux binary - - docs: add doc/TESTING - - docs: remove Bonus section from README.rst - -0.1.0 2015/04/10 - - MSS: fix code with YAPF tool - - Linux: fully functional using Xrandr library - - Linux: code purgation (no more XML files to parse) - - docs: better tests and examples - -0.0.8 2015/02/04 - - new contributors: sergey-vin, Alexander 'thehesiod' Mohr - - MSS: fix #3, filename's dir is not used when saving - - MSS: fix "E713 test for membership should be 'not in'" - - MSS: raise an exception for unimplemented methods - - Windows: fix #4, robustness to MSSWindows.get_pixels - -0.0.7 2014/03/20 - - MSS: fix path where screenshots are saved - -0.0.6 2014/03/19 - - new contributor: Sam from sametmax.com - - Python 3.4 ready - - PEP8 compliant - - MSS: review module structure to fit the "Code Like a Pythonista: Idiomatic Python" - - MSS: refactoring of all enum_display_monitors() methods - - MSS: fix misspellings using 'codespell' tool - - MSS: better way to manage output filenames (callback) - - MSS: several fixes here and there, code refactoring - - MSS: moved into a MSS:save_img() method - - Linux: add XFCE4 support - - CLI: possibility to append '--debug' to the command line - -0.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 - -0.0.3 2013/10/30 - - MSS: remove PNG filters - - MSS: remove '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 - - new contributors: Oros, Eownis - - add support for python 3 on Windows and GNU/Linux - -0.0.1 2013/07/01 - - first release diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..3c7b25e6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,314 @@ +# History + +See Git checking messages for full history. + +## 10.1.1.dev0 (2025-xx-xx) +- Linux: check the server for Xrandr support version (#417) +- Linux: improve typing and error messages for X libraries (#418) +- Linux: add a new XCB backend for better thread safety, error-checking, and future development (#425) +- :heart: contributors: @jholveck + +## 10.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 + +## 10.0.0 (2024-11-14) +- removed support for Python 3.8 +- added support for Python 3.14 +- Linux: fixed a threadding issue in `.close()` when calling `XCloseDisplay()` (#251) +- Linux: minor optimization when checking for a X extension status (#251) +- :heart: contributors: @kianmeng, @shravanasati, @mgorny + +## 9.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) +- MSS: fixed `Pixel` model type (#274) +- CI: automated release publishing on tag creation +- :heart: contributors: @Andon-Li + +## 9.0.1 (2023-04-20) +- CLI: fixed entry point not taking into account arguments + +## 9.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) +- Windows: removed side effects when leaving the context manager, resources are all freed (fixes #209) +- CI: run tests via `xvfb-run` on GitHub Actions (#248) +- tests: enhance `test_get_pixels.py`, and try to fix a random failure at the same time (related to #251) +- tests: use `PyVirtualDisplay` instead of `xvfbwrapper` (#249) +- tests: automatic rerun in case of failure (related to #251) +- :heart: contributors: @mgorny, @CTPaHHuK-HEbA + +## 8.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 +- 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) +- fixed `SetuptoolsDeprecationWarning`: Installing 'XXX' as data is deprecated, please list it in packages +- CLI: fixed arguments handling + +## 8.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 ` +- tests: fixed `test_entry_point()` with multiple monitors having the same resolution + +## 8.0.0 (2023-04-09) +- removed support for Python 3.6 +- removed support for Python 3.7 +- MSS: fixed PEP 484 prohibits implicit Optional +- MSS: the whole source code was migrated to PEP 570 (Python positional-only parameters) +- Linux: reset the X server error handler on exit to prevent issues with Tk/Tkinter (fixes #220) +- Linux: refactored how internal handles are stored to fixed issues with multiple X servers (fixes #210) +- 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 +- :heart: contributors: @zorvios + +## 7.0.1 (2022-10-27) +- fixed the wheel package + +## 7.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 +- docs: fixed an error when building the documentation + +## 6.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 interpreter not found on Travis-CI +- tests: fixed `test_entry_point()` when there are several monitors + +## 6.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) +- 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) +- 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 +- tests: fixed tests on macOS with Retina display +- Windows: fixed multi-thread safety (fixes #150) +- :heart: contributors: @narumishi + +## 5.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 +- 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) +- Windows: ignore missing `SetProcessDPIAware()` on Window XP (fixes #109) +- :heart: contributors: @foone + +## 4.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) +- 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 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 + +## 3.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) +- Linux: fixed a memory leak introduced with 7e8ae5703f0669f40532c2be917df4328bc3985e (fixes #72) +- docs: add the download statistics badge + +## 3.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 +- docs: fixed a typo in `Screenshot.pixel()` method (thanks to @mchlnix) +- big code clean-up using `black` + +## 3.2.1 (2018-05-21) +- Windows: enable Hi-DPI awareness +- :heart: contributors: @ryanfox + +## 3.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 +- 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) +- removed support for Python 3.3 +- MSS: possibility to get the whole PNG raw bytes +- Windows: capture all visible window +- docs: improvements and fixes (fixes #37) +- CI: build the documentation + +## 3.1.1 (2017-11-27) +- MSS: add the `mss` entry point + +## 3.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 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) +- 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) +- fixed examples links + +## 3.0.0 (2017-07-06) +- big refactor, introducing the `ScreenShot` class +- MSS: add Numpy array interface support to the `Screenshot` class +- docs: add OpenCV/Numpy, PIL pixels, FPS + +## 2.0.22 (2017-04-29) +- MSS: better use of exception mechanism +- 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) +- docs: add an example to capture only a part of the screen +- :heart: contributors: David Becker, @redodo + +## 2.0.18 (2016-12-03) +- change license to MIT +- MSS: add type hints +- MSS: remove unused code (reported by `Vulture`) +- Linux: remove MSS library +- Linux: insanely fast using only ctypes +- Linux: skip unused monitors +- Linux: use `errcheck` instead of deprecated `restype` with callable (fixes #11) +- Linux: fixed security issue (reported by Bandit) +- docs: add documentation (fixes #10) +- tests: add tests and use Travis CI (fixes #9) +- :heart: contributors: @cycomanic + +## 2.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 +- MSS: rename `save_img()` to `to_png()` +- MSS: `save()`: replace `screen` argument by `mon` +- Mac: get rid of the `PyObjC` module, 100% ctypes +- 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: screenshot not correct on Windows 8 (fixes #6) + +## 1.0.2 (2016-04-22) +- MSS: fixed non-existent alias + +## 1.0.1 (2016-04-22) +- MSS: `libpng` warning (ignoring bad filter type) (fixes #7) + +## 1.0.0 (2015-04-16) +- Python 2.6 to 3.5 ready +- 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()` +- Linux: fixed `object has no attribute 'display' into __del__` +- Linux: use of `XDestroyImage()` instead of `XFree()` +- Linux: optimizations of `get_pixels()` +- Windows: huge optimization of `get_pixels()` +- CLI: delete `--debug` argument + +## 0.1.1 (2015-04-10) +- MSS: little code review +- Linux: fixed monitor count +- tests: remove `test-linux` binary +- docs: add `doc/TESTING` +- docs: remove Bonus section from README + +## 0.1.0 (2015-04-10) +- MSS: fixed code with `YAPF` tool +- Linux: fully functional using Xrandr library +- Linux: code clean-up (no more XML files to parse) +- docs: better tests and examples + +## 0.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) +- MSS: fixed path where screenshots are saved + +## 0.0.6 (2014-03-19) +- Python 3.4 ready +- PEP8 compliant +- MSS: review module structure to fit the "Code Like a Pythonista: Idiomatic Python" +- MSS: refactoring of all `enum_display_monitors()` methods +- MSS: fixed misspellings using `codespell` tool +- MSS: better way to manage output filenames (callback) +- MSS: several fixes here and there, code refactoring +- Linux: add XFCE4 support +- CLI: possibility to append `--debug` to the command line +- :heart: contributors: @sametmax + +## 0.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 + +## 0.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) +- added support for python 3 on Windows and GNU/Linux +- :heart: contributors: Oros, Eownis + +## 0.0.1 (2013-07-01) +- first release diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000..231568e3 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,224 @@ +# Technical Changes + +## 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 +- Removed `XEvent` class. Use `XErrorEvent` instead. + +### windows.py +- Added `MSS.close()` method +- Removed `MSS.bmp` attribute +- Removed `MSS.memdc` attribute + +## 8.0.3 (2023-04-15) + +### linux.py +- Added `XErrorEvent` class (old `Event` class is just an alias now, and will be removed in v9.0.0) + +## 8.0.0 (2023-04-09) + +### base.py +- Added `compression_level=6` keyword argument to `MSS.__init__()` +- Added `display=None` keyword argument to `MSS.__init__()` +- Added `max_displays=32` keyword argument to `MSS.__init__()` +- Added `with_cursor=False` keyword argument to `MSS.__init__()` +- Added `MSS.with_cursor` attribute + +### linux.py +- Added `MSS.close()` +- Moved `MSS.__init__()` keyword arguments handling to the base class +- Renamed `error_handler()` function to `_error_handler()` +- Renamed `validate()` function to `__validate()` +- Renamed `MSS.has_extension()` method to `_is_extension_enabled()` +- Removed `ERROR` namespace +- Removed `MSS.drawable` attribute +- Removed `MSS.root` attribute +- Removed `MSS.get_error_details()` method. Use `ScreenShotError.details` attribute instead. + +## 6.1.0 (2020-10-31) + +### darwin.py +- Added `CFUNCTIONS` + +### linux.py +- Added `CFUNCTIONS` + +### windows.py +- Added `CFUNCTIONS` +- Added `MONITORNUMPROC` +- Removed `MSS.monitorenumproc`. Use `MONITORNUMPROC` instead. + +## 6.0.0 (2020-06-30) + +### base.py +- Added `lock` +- Added `MSS._grab_impl()` (abstract method) +- Added `MSS._monitors_impl()` (abstract method) +- `MSS.grab()` is no more an abstract method +- `MSS.monitors` is no more an abstract property + +### darwin.py +- Renamed `MSS.grab()` to `MSS._grab_impl()` +- Renamed `MSS.monitors` to `MSS._monitors_impl()` + +### linux.py +- Added `MSS.has_extension()` +- Removed `MSS.display` +- Renamed `MSS.grab()` to `MSS._grab_impl()` +- Renamed `MSS.monitors` to `MSS._monitors_impl()` + +### windows.py +- Removed `MSS._lock` +- Renamed `MSS.srcdc_dict` to `MSS._srcdc_dict` +- Renamed `MSS.grab()` to `MSS._grab_impl()` +- Renamed `MSS.monitors` to `MSS._monitors_impl()` + +## 5.1.0 (2020-04-30) + +### base.py +- Renamed back `MSSMixin` class to `MSSBase` +- `MSSBase` is now derived from `abc.ABCMeta` +- `MSSBase.monitor` is now an abstract property +- `MSSBase.grab()` is now an abstract method + +### windows.py +- Replaced `MSS.srcdc` with `MSS.srcdc_dict` + +## 5.0.0 (2019-12-31) + +### darwin.py +- Added `MSS.__slots__` + +### linux.py +- Added `MSS.__slots__` +- Deleted `MSS.close()` +- Deleted `LAST_ERROR` constant. Use `ERROR` namespace instead, specially the `ERROR.details` attribute. + +### models.py +- Added `Monitor` +- Added `Monitors` +- Added `Pixel` +- Added `Pixels` +- Added `Pos` +- Added `Size` + +### screenshot.py +- Added `ScreenShot.__slots__` +- Removed `Pos`. Use `models.Pos` instead. +- Removed `Size`. Use `models.Size` instead. + +### windows.py +- Added `MSS.__slots__` +- Deleted `MSS.close()` + +## 4.0.1 (2019-01-26) + +### linux.py +- Removed use of `MSS.xlib.XDefaultScreen()` +4.0.0 (2019-01-11) + +### base.py +- Renamed `MSSBase` class to `MSSMixin` + +### linux.py +- Renamed `MSS.__del__()` method to `MSS.close()` +- Deleted `MSS.last_error` attribute. Use `LAST_ERROR` constant instead. +- Added `validate()` function +- Added `MSS.get_error_details()` method + +### windows.py +- Renamed `MSS.__exit__()` method to `MSS.close()` + +## 3.3.0 (2018-09-04) + +### exception.py +- Added `details` attribute to `ScreenShotError` exception. Empty dict by default. + +### linux.py +- Added `error_handler()` function + +## 3.2.1 (2018-05-21) + +### windows.py +- Removed `MSS.scale_factor` property +- Removed `MSS.scale()` method + +## 3.2.0 (2018-03-22) + +### base.py +- Added `MSSBase.compression_level` attribute + +### linux.py +- Added `MSS.drawable` attribute + +### screenshot.py +- Added `Screenshot.bgra` attribute + +### tools.py +- Changed signature of `to_png(data, size, output=None)` to `to_png(data, size, level=6, output=None)`. `level` is the Zlib compression level. + +## 3.1.2 (2018-01-05) + +### tools.py +- Changed signature of `to_png(data, size, output)` to `to_png(data, size, output=None)`. If `output` is `None`, the raw PNG bytes will be returned. + +## 3.1.1 (2017-11-27) + +### \_\_main\_\_.py +- Added `args` argument to `main()` + +### base.py +- Moved `ScreenShot` class to `screenshot.py` + +### darwin.py +- Added `CGPoint.__repr__()` function +- Added `CGRect.__repr__()` function +- Added `CGSize.__repr__()` function +- Removed `get_infinity()` function + +### windows.py +- Added `MSS.scale()` method +- Added `MSS.scale_factor` property + +## 3.0.0 (2017-07-06) + +### base.py +- 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. +- Removed `enum_display_monitors()` method. Use `monitors` property instead. +- Removed `monitors` attribute. Use `monitors` property instead. +- Removed `width` attribute. Use `ScreenShot.size[0]` attribute or `ScreenShot.width` property instead. +- Removed `height` attribute. Use `ScreenShot.size[1]` attribute or `ScreenShot.height` property instead. +- Removed `image`. Use the `ScreenShot.raw` attribute or `ScreenShot.rgb` property instead. +- Removed `bgra_to_rgb()` method. Use `ScreenShot.rgb` property instead. + +### darwin.py +- Removed `_crop_width()` method. Screenshots are now using the width set by the OS (rounded to 16). + +### exception.py +- Renamed `ScreenshotError` class to `ScreenShotError` + +### tools.py +- Changed signature of `to_png(data, monitor, output)` to `to_png(data, size, output)` where `size` is a `tuple(width, height)` diff --git a/CHANGES.rst b/CHANGES.rst deleted file mode 100644 index 7cd482fe..00000000 --- a/CHANGES.rst +++ /dev/null @@ -1,225 +0,0 @@ -6.1.0 (2020-10-31) -================== - -darwin.py ---------- - - Added ``CFUNCTIONS`` - -linux.py --------- - - Added ``CFUNCTIONS`` - -windows.py ----------- - - Added ``CFUNCTIONS`` - - Added ``MONITORNUMPROC`` - - Removed ``MSS.monitorenumproc``. Use ``MONITORNUMPROC`` instead. - - -6.0.0 (2020-06-30) -================== - -base.py -------- - - Added ``lock`` - - Added ``MSS._grab_impl()`` (abstract method) - - Added ``MSS._monitors_impl()`` (abstract method) - - ``MSS.grab()`` is no more an abstract method - - ``MSS.monitors`` is no more an abstract property - -darwin.py ---------- - - Renamed ``MSS.grab()`` to ``MSS._grab_impl()`` - - Renamed ``MSS.monitors`` to ``MSS._monitors_impl()`` - -linux.py --------- - - Added ``MSS.has_extension()`` - - Removed ``MSS.display`` - - Renamed ``MSS.grab()`` to ``MSS._grab_impl()`` - - Renamed ``MSS.monitors`` to ``MSS._monitors_impl()`` - -windows.py ----------- - - Removed ``MSS._lock`` - - Renamed ``MSS.srcdc_dict`` to ``MSS._srcdc_dict`` - - Renamed ``MSS.grab()`` to ``MSS._grab_impl()`` - - Renamed ``MSS.monitors`` to ``MSS._monitors_impl()`` - - -5.1.0 (2020-04-30) -================== - -base.py -------- -- Renamed back ``MSSMixin`` class to ``MSSBase`` -- ``MSSBase`` is now derived from ``abc.ABCMeta`` -- ``MSSBase.monitor`` is now an abstract property -- ``MSSBase.grab()`` is now an abstract method - -windows.py ----------- - - Replaced ``MSS.srcdc`` with ``MSS.srcdc_dict`` - - -5.0.0 (2019-12-31) -================== - -darwin.py ---------- -- Added `MSS.__slots__` - -linux.py --------- -- Added `MSS.__slots__` -- Deleted `MSS.close()` -- Deleted ``LAST_ERROR`` constant. Use ``ERROR`` namespace instead, specially the ``ERROR.details`` attribute. - -models.py ---------- -- Added ``Monitor`` -- Added ``Monitors`` -- Added ``Pixel`` -- Added ``Pixels`` -- Added ``Pos`` -- Added ``Size`` - -screenshot.py -------------- -- Added `ScreenShot.__slots__` -- Removed ``Pos``. Use ``models.Pos`` instead. -- Removed ``Size``. Use ``models.Size`` instead. - -windows.py ----------- -- Added `MSS.__slots__` -- Deleted `MSS.close()` - - -4.0.1 (2019-01-26) -================== - -linux.py --------- -- Removed use of ``MSS.xlib.XDefaultScreen()`` - - -4.0.0 (2019-01-11) -================== - -base.py -------- -- Renamed ``MSSBase`` class to ``MSSMixin`` - -linux.py --------- -- Renamed ``MSS.__del__()`` method to ``MSS.close()`` -- Deleted ``MSS.last_error`` attribute. Use ``LAST_ERROR`` constant instead. -- Added ``validate()`` function -- Added ``MSS.get_error_details()`` method - -windows.py ----------- -- Renamed ``MSS.__exit__()`` method to ``MSS.close()`` - - -3.3.0 (2018-09-04) -================== - -exception.py ------------- -- Added ``details`` attribute to ``ScreenShotError`` exception. Empty dict by default. - -linux.py --------- -- Added ``error_handler()`` function - - -3.2.1 (2018-05-21) -================== - -windows.py ----------- -- Removed ``MSS.scale_factor`` property -- Removed ``MSS.scale()`` method - - -3.2.0 (2018-03-22) -================== - -base.py -------- -- Added ``MSSBase.compression_level`` to control the PNG compression level - -linux.py --------- -- Added ``MSS.drawable`` to speed-up grabbing. - -screenshot.py -------------- -- Added ``Screenshot.bgra`` to get BGRA bytes. - -tools.py --------- -- Changed signature of ``to_png(data, size, output=None)`` to ``to_png(data, size, level=6, output=None)``. ``level`` is the Zlib compression level. - - -3.1.2 (2018-01-05) -================== - -tools.py --------- -- Changed signature of ``to_png(data, size, output)`` to ``to_png(data, size, output=None)``. If ``output`` is ``None``, the raw PNG bytes will be returned. - - -3.1.1 (2017-11-27) -================== - -__main__.py ------------ -- Added ``args`` argument to ``main()`` - -base.py -------- -- Moved ``ScreenShot`` class to screenshot.py - -darwin.py ---------- -- Added ``CGPoint.__repr__()`` -- Added ``CGRect.__repr__()`` -- Added ``CGSize.__repr__()`` -- Removed ``get_infinity()`` function - -windows.py ----------- -- Added ``scale()`` method to ``MSS`` class -- Added ``scale_factor`` property to ``MSS`` class - - -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 ``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. -- Removed ``enum_display_monitors()`` method. Use ``monitors`` property instead. -- Removed ``monitors`` attribute. Use ``monitors`` property instead. -- Removed ``width`` attribute. Use ``ScreenShot.size[0]`` attribute or ``ScreenShot.width`` property instead. -- Removed ``height`` attribute. Use ``ScreenShot.size[1]`` attribute or ``ScreenShot.height`` property instead. -- Removed ``image``. Use the ``ScreenShot.raw`` attribute or ``ScreenShot.rgb`` property instead. -- 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). - -exception.py ------------- -- Renamed ``ScreenshotError`` class to ``ScreenShotError`` - -tools.py --------- -- Changed signature of ``to_png(data, monitor, output)`` to ``to_png(data, size, output)`` where ``size`` is a ``tuple(width, height)`` diff --git a/CONTRIBUTORS b/CONTRIBUTORS deleted file mode 100644 index 00971c5c..00000000 --- a/CONTRIBUTORS +++ /dev/null @@ -1,53 +0,0 @@ -# Many thanks to all those who helped :) -# (sorted alphabetically) - -# Nickname or fullname [URL] [URL2] [URLN] -# - major contribution -# - major contribution 2 -# - major contribution N - -Alexander 'thehesiod' Mohr [https://github.com/thehesiod] - - Windows: robustness to MSS.get_pixels() - -Andreas Buhr [https://www.andreasbuhr.de] - - Bugfix for multi-monitor detection - -bubulle [http://indexerror.net/user/bubulle] - - Windows: efficiency of MSS.get_pixels() - -Condé 'Eownis' Titouan [https://titouan.co] - - MacOS X tester - -David Becker [https://davide.me] and redodo [https://github.com/redodo] - - Mac: Take into account extra black pixels added when screen with is not divisible by 16 - -Hugo van Kemenade [https://github.com/hugovk] - - Drop support for legacy Python 2.7 - -Jochen 'cycomanic' Schroeder [https://github.com/cycomanic] - - GNU/Linux: use errcheck instead of deprecated restype with callable, for enum_display_monitors() - -Karan Lyons [https://karanlyons.com] [https://github.com/karanlyons] - - MacOS: Proper support for display scaling - -narumi [https://github.com/narumishi] - - Windows: fix multi-thread unsafe - -Oros [https://ecirtam.net] - - GNU/Linux tester - -Ryan Fox ryan@foxrow.com [https://foxrow.com] - - Windows fullscreen shots on HiDPI screens - -Sam [http://sametmax.com] [https://github.com/sametmax] - - code review and advices - - the factory - -sergey-vin [https://github.com/sergey-vin] - - bug report - -yoch [http://indexerror.net/user/yoch] - - Windows: efficiency of MSS.get_pixels() - -Wagoun - - equipment loan (Macbook Pro) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 00000000..fcf2810d --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,18 @@ +# Contributors + +The full list can be found here: https://github.com/BoboTiG/python-mss/graphs/contributors + +That document is mostly useful for users without a GitHub account (sorted alphabetically): + +- [bubulle](http://indexerror.net/user/bubulle) + - Windows: efficiency of MSS.get_pixels() +- [Condé 'Eownis' Titouan](https://titouan.co) + - MacOS X tester +- [David Becker](https://davide.me) + - Mac: Take into account extra black pixels added when screen with is not divisible by 16 +- [Oros](https://ecirtam.net) + - GNU/Linux tester +- [yoch](http://indexerror.net/user/yoch) + - Windows: efficiency of `MSS.get_pixels()` +- Wagoun + - equipment loan (Macbook Pro) diff --git a/LICENSE b/LICENSE.txt similarity index 94% rename from LICENSE rename to LICENSE.txt index e2acbe65..0b055a04 100644 --- a/LICENSE +++ b/LICENSE.txt @@ -1,5 +1,5 @@ MIT License -Copyright (c) 2016-2020, Mickaël 'Tiger-222' Schoentgen +Copyright (c) 2013-2025, 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/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b8cdc5d1..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -# Include tests files and data -include mss/tests/*.py -recursive-include mss/tests/res * diff --git a/README.md b/README.md new file mode 100644 index 00000000..70d1ca92 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Python MSS + +[![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) +[![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 + +# 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.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 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 [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 ScreenShots; + + +## Installation + +You can install it with pip: + +```shell +python -m pip install -U --user mss +``` + +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/README.rst b/README.rst deleted file mode 100644 index e076e615..00000000 --- a/README.rst +++ /dev/null @@ -1,49 +0,0 @@ -Python MSS -========== - -.. image:: https://travis-ci.org/BoboTiG/python-mss.svg?branch=master - :target: https://travis-ci.org/BoboTiG/python-mss -.. image:: https://ci.appveyor.com/api/projects/status/72dik18r6b746mb0?svg=true - :target: https://ci.appveyor.com/project/BoboTiG/python-mss -.. image:: https://img.shields.io/badge/say-thanks-ff69b4.svg - :target: https://saythanks.io/to/BoboTiG -.. image:: https://pepy.tech/badge/mss - :target: https://pepy.tech/project/mss -.. image:: https://anaconda.org/conda-forge/python-mss/badges/installer/conda.svg - :target: https://anaconda.org/conda-forge/python-mss - - -.. code-block:: python - - from mss import mss - - # The simplest use, save a screen shot 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.5+** and 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; -- 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); -- 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 `_; -- and there is a `complete, and beautiful, documentation `_ :) -- **MSS** stands for Multiple Screen Shots; - - -Installation ------------- - -You can install it with pip:: - - python -m pip install -U --user mss - -Or you can install it with conda:: - - conda install -c conda-forge python-mss diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index c96af385..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,38 +0,0 @@ -build: off - -image: - - Visual Studio 2019 - -platform: - - x64 - - x86 - -environment: - fast_finish: true - matrix: - - PYTHON_VERSION: 3.10 - - PYTHON_VERSION: 3.9 - - PYTHON_VERSION: 3.8 - - PYTHON_VERSION: 3.7 - - PYTHON_VERSION: 3.6 - - PYTHON_VERSION: 3.5 - -matrix: - allow_failures: - - PYTHON_VERSION: 3.10 - - PYTHON_VERSION: 3.9 - -init: - # Update Environment Variables based on matrix/platform - - set PY_VER=%PYTHON_VERSION:.=% - - set PYTHON=C:\PYTHON%PY_VER% - - if %PLATFORM%==x64 (set PYTHON=%PYTHON%-x64) - - # Put desired Python version first in PATH - - set PATH=%PYTHON%;%PYTHON%\Scripts;%PATH% - -install: - - python -m pip install --upgrade pip tox - -test_script: - - tox -e py%PY_VER% diff --git a/check.sh b/check.sh new file mode 100755 index 00000000..d07b3576 --- /dev/null +++ b/check.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# +# Small script to ensure quality checks pass before submitting a commit/PR. +# +set -eu + +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/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 49fdf113..b1d87f4b 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -12,6 +12,22 @@ macOS .. attribute:: CFUNCTIONS + .. versionadded:: 6.1.0 + +.. function:: cgfloat + +.. class:: CGPoint + +.. class:: CGSize + +.. class:: CGRect + +.. class:: MSS + + .. attribute:: core + + .. attribute:: max_displays + GNU/Linux --------- @@ -19,75 +35,49 @@ GNU/Linux .. attribute:: CFUNCTIONS -.. attribute:: ERROR - - :type: types.SimpleNamspacedict - - The `details` attribute contains the latest Xlib or XRANDR function. It is a dict. - - .. versionadded:: 5.0.0 + .. versionadded:: 6.1.0 .. attribute:: PLAINMASK .. attribute:: ZPIXMAP -.. class:: MSS +.. class:: Display - .. method:: __init__([display=None]) + Structure that serves as the connection to the X server, and that contains all the information about that X server. - :type display: str or None - :param display: The display to use. +.. class:: XErrorEvent - GNU/Linux initializations. + XErrorEvent to debug eventual errors. - .. method:: get_error_details() +.. class:: XFixesCursorImage - :rtype: Optional[dict[str, Any]] + Cursor structure - Get more information about the latest X server error. To use in such scenario:: +.. class:: XImage - with mss.mss() as sct: - # Take a screenshot of a region out of monitor bounds - rect = {"left": -30, "top": 0, "width": 100, "height": 100} + Description of an image as it exists in the client's memory. - try: - sct.grab(rect) - except ScreenShotError: - details = sct.get_error_details() - """ - >>> import pprint - >>> pprint.pprint(details) - {'xerror': 'BadFont (invalid Font parameter)', - 'xerror_details': {'error_code': 7, - 'minor_code': 0, - 'request_code': 0, - 'serial': 422, - 'type': 0}} - """ +.. class:: XRRCrtcInfo - .. versionadded:: 4.0.0 + Structure that contains CRTC information. + +.. class:: XRRModeInfo - .. method:: grab(monitor) +.. class:: XRRScreenResources - :rtype: :class:`~mss.base.ScreenShot` - :raises ScreenShotError: When color depth is not 32 (rare). + Structure that contains arrays of XIDs that point to the available outputs and associated CRTCs. - See :meth:`~mss.base.MSSBase.grab()` for details. +.. class:: XWindowAttributes -.. function:: error_handler(display, event) + Attributes for the specified window. - :type display: ctypes.POINTER(Display) - :param display: The display impacted by the error. - :type event: ctypes.POINTER(Event) - :param event: XError details. - :return int: Always ``0``. +.. class:: MSS - Error handler passed to `X11.XSetErrorHandler()` to catch any error that can happen when calling a X11 function. - This will prevent Python interpreter crashes. + .. method:: close() - When such an error happen, a :class:`~mss.exception.ScreenShotError` exception is raised and all `XError` information are added to the :attr:`~mss.exception.ScreenShotError.details` attribute. + Clean-up method. - .. versionadded:: 3.3.0 + .. versionadded:: 8.0.0 Windows ------- @@ -98,22 +88,70 @@ Windows .. 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. Does nothing by default. + Clean-up method. .. versionadded:: 4.0.0 @@ -135,18 +173,18 @@ Methods :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. + :param callback: callback called before saving the screenshot 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. + Grab a screenshot 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 + - ``{top}``: the screenshot y-coordinate of the upper-left corner + - ``{left}``: the screenshot x-coordinate of the upper-left corner + - ``{width}``: the screenshot's width + - ``{height}``: the screenshot'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}``. @@ -161,14 +199,14 @@ Methods :return str: The created file. - Helper to save the screen shot of the first monitor, by default. + Helper to save the screenshot 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. + Screenshot object. .. note:: @@ -183,7 +221,7 @@ Methods :param int height: the monitor's height. :rtype: :class:`ScreenShot` - Instantiate a new class given only screen shot's data and size. + Instantiate a new class given only screenshot's data and size. .. method:: pixel(coord_x, coord_y) @@ -262,25 +300,25 @@ Properties .. attribute:: height - The screen shot's height. + The screenshot's height. :rtype: int .. attribute:: left - The screen shot's left coordinate. + The screenshot's left coordinate. :rtype: int .. attribute:: pixels - List of RGB tuples. + List of row tuples that contain RGB tuples. - :rtype: list[tuple(int, int, int)] + :rtype: list[tuple(tuple(int, int, int), ...)] .. attribute:: pos - The screen shot's coordinates. + The screenshot's coordinates. :rtype: :py:func:`collections.namedtuple()` @@ -294,19 +332,19 @@ Properties .. attribute:: size - The screen shot's size. + The screenshot's size. :rtype: :py:func:`collections.namedtuple()` .. attribute:: top - The screen shot's top coordinate. + The screenshot's top coordinate. :rtype: int .. attribute:: width - The screen shot's width. + The screenshot's width. :rtype: int diff --git a/docs/source/conf.py b/docs/source/conf.py index 2c5b6c45..a0d9b993 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,62 +1,52 @@ -# -- General configuration ------------------------------------------------ +# Lets prevent misses, and import the module to get the proper version. +# So that the version in only defined once across the whole code base: +# src/mss/__init__.py +import sys +from pathlib import Path -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = ["sphinx.ext.intersphinx"] +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] +import mss -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" +# -- General configuration ------------------------------------------------ -# The master toctree document. +extensions = [ + "sphinx_copybutton", + "sphinx.ext.intersphinx", + "sphinx_new_tab_link", +] +templates_path = ["_templates"] +source_suffix = {".rst": "restructuredtext"} master_doc = "index" +new_tab_link_show_external_link_icon = True # General information about the project. project = "Python MSS" -copyright = "2013-2020, Mickaël 'Tiger-222' Schoentgen & contributors" -author = "Tiger-222" - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = "6.1.0" +copyright = f"{mss.__date__}, {mss.__author__} & contributors" # noqa:A001 +author = mss.__author__ +version = mss.__version__ -# The full version, including alpha/beta/rc tags. release = "latest" - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = [] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - +language = "en" todo_include_todos = True # -- Options for HTML output ---------------------------------------------- -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = "default" - -# Output file base name for HTML help builder. +html_theme = "shibuya" +html_theme_options = { + "accent_color": "lime", + "globaltoc_expand_depth": 1, + "toctree_titles_only": 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" @@ -75,4 +65,4 @@ # ---------------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"https://docs.python.org/3/": None} +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} diff --git a/docs/source/developers.rst b/docs/source/developers.rst index db544bba..3dfe19bc 100644 --- a/docs/source/developers.rst +++ b/docs/source/developers.rst @@ -11,11 +11,6 @@ Setup 2. Create you own branch. 3. Be sure to add/update tests and documentation within your patch. -Additionally, you can install `pre-commit `_ to ensure you are doing things well:: - - $ python -m pip install -U --user pre-commit - $ pre-commit install - Testing ======= @@ -23,9 +18,12 @@ Testing Dependency ---------- -You will need `tox `_:: +You will need `pytest `_:: - $ python -m pip install -U --user tox + $ python -m venv venv + $ . venv/bin/activate + $ python -m pip install -U pip + $ python -m pip install -e '.[tests]' How to Test? @@ -33,28 +31,16 @@ How to Test? Launch the test suit:: - $ tox - - # or - $ TOXENV=py37 tox - -This will test MSS and ensure a good code quality. + $ python -m pytest Code Quality ============ -To ensure the code is always well enough using `flake8 `_:: - - $ TOXENV=lint tox - - -Static Type Checking -==================== - -To check type annotation using `mypy `_:: +To ensure the code quality is correct enough:: - $ TOXENV=types tox + $ python -m pip install -e '.[dev]' + $ ./check.sh Documentation @@ -62,4 +48,5 @@ Documentation To build the documentation, simply type:: - $ TOXENV=docs tox + $ python -m pip install -e '.[docs]' + $ sphinx-build -d docs docs/source docs_out --color -W -bhtml diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 137715fd..7bb8157b 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 @@ -77,13 +77,29 @@ You can tweak the PNG compression level (see :py:func:`zlib.compress()` for deta .. versionadded:: 3.2.0 +Get PNG bytes, no file output +----------------------------- + +You can get the bytes of the PNG image: +:: + + with mss.mss() as sct: + # The monitor or screen part to capture + monitor = sct.monitors[1] # or a region + + # Grab the data + sct_img = sct.grab(monitor) + + # Generate the PNG + png = mss.tools.to_png(sct_img.rgb, sct_img.size) + Advanced ======== You can handle data using a custom class: .. literalinclude:: examples/custom_cls_image.py - :lines: 8- + :lines: 7- .. versionadded:: 3.1.0 @@ -94,7 +110,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 @@ -104,7 +120,7 @@ Playing with pixels This is an example using `putdata() `_: .. literalinclude:: examples/pil_pixels.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -116,7 +132,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 @@ -129,7 +145,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 @@ -140,7 +156,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 diff --git a/docs/source/examples/callback.py b/docs/source/examples/callback.py index ee79774c..5a93d122 100644 --- a/docs/source/examples/callback.py +++ b/docs/source/examples/callback.py @@ -1,26 +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. Screenshot of the monitor 1, with callback. """ -import os -import os.path +from pathlib import Path import mss -def on_exists(fname): - # type: (str) -> None - """ - Callback example when we try to overwrite an existing screenshot. - """ - - if os.path.isfile(fname): - newfile = fname + ".old" - print("{} -> {}".format(fname, newfile)) - os.rename(fname, newfile) +def on_exists(fname: str) -> None: + """Callback example when we try to overwrite an existing screenshot.""" + file = Path(fname) + if file.is_file(): + newfile = file.with_name(f"{file.name}.old") + print(f"{fname} → {newfile}") + file.rename(newfile) with mss.mss() as sct: diff --git a/docs/source/examples/custom_cls_image.py b/docs/source/examples/custom_cls_image.py index 4e5d8757..2a1f8102 100644 --- a/docs/source/examples/custom_cls_image.py +++ b/docs/source/examples/custom_cls_image.py @@ -1,26 +1,28 @@ -""" -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. Screenshot of the monitor 1, using a custom class to handle the data. """ +from typing import Any + import mss +from mss.models import Monitor +from mss.screenshot import ScreenShot -class SimpleScreenShot: - """ - Define your own custom method to deal with screen shot raw data. +class SimpleScreenShot(ScreenShot): + """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. """ - def __init__(self, data, monitor, **kwargs): + def __init__(self, data: bytearray, monitor: Monitor, **_: Any) -> None: self.data = data self.monitor = monitor with mss.mss() as sct: - sct.cls_image = SimpleScreenShot # type: ignore + 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 e8123780..f9e76134 100644 --- a/docs/source/examples/fps.py +++ b/docs/source/examples/fps.py @@ -1,6 +1,5 @@ -""" -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. Simple naive benchmark to compare with: https://pythonprogramming.net/game-frames-open-cv-python-plays-gta-v/ @@ -9,16 +8,13 @@ import time import cv2 -import mss -import numpy +import numpy as np +from PIL import ImageGrab +import mss -def screen_record(): - try: - from PIL import ImageGrab - except ImportError: - return 0 +def screen_record() -> int: # 800x600 windowed mode mon = (0, 40, 800, 640) @@ -27,7 +23,7 @@ def screen_record(): last_time = time.time() while time.time() - last_time < 1: - img = numpy.asarray(ImageGrab.grab(bbox=mon)) + img = np.asarray(ImageGrab.grab(bbox=mon)) fps += 1 cv2.imshow(title, cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) @@ -38,7 +34,7 @@ def screen_record(): return fps -def screen_record_efficient(): +def screen_record_efficient() -> int: # 800x600 windowed mode mon = {"top": 40, "left": 0, "width": 800, "height": 640} @@ -48,7 +44,7 @@ def screen_record_efficient(): last_time = time.time() while time.time() - last_time < 1: - img = numpy.asarray(sct.grab(mon)) + img = np.asarray(sct.grab(mon)) fps += 1 cv2.imshow(title, img) diff --git a/docs/source/examples/fps_multiprocessing.py b/docs/source/examples/fps_multiprocessing.py index d229cb0a..c4a2a38a 100644 --- a/docs/source/examples/fps_multiprocessing.py +++ b/docs/source/examples/fps_multiprocessing.py @@ -1,6 +1,5 @@ -""" -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. Example using the multiprocessing module to speed-up screen capture. https://github.com/pythonlessons/TensorFlow-object-detection-tutorial @@ -12,9 +11,7 @@ import mss.tools -def grab(queue): - # type: (Queue) -> None - +def grab(queue: Queue) -> None: rect = {"top": 0, "left": 0, "width": 600, "height": 800} with mss.mss() as sct: @@ -25,9 +22,7 @@ def grab(queue): queue.put(None) -def save(queue): - # type: (Queue) -> None - +def save(queue: Queue) -> None: number = 0 output = "screenshots/file_{}.png" to_png = mss.tools.to_png @@ -43,8 +38,8 @@ def save(queue): if __name__ == "__main__": # The screenshots queue - queue = Queue() # type: 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 0e56cec1..3c5297b9 100644 --- a/docs/source/examples/from_pil_tuple.py +++ b/docs/source/examples/from_pil_tuple.py @@ -1,6 +1,5 @@ -""" -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. Use PIL bbox style and percent values. """ @@ -8,7 +7,6 @@ import mss import mss.tools - with mss.mss() as sct: # Use the 1st monitor monitor = sct.monitors[1] @@ -23,7 +21,7 @@ # Grab the picture # Using PIL would be something like: # im = ImageGrab(bbox=bbox) - im = sct.grab(bbox) # type: ignore + im = sct.grab(bbox) # Save it! mss.tools.to_png(im.rgb, im.size, output="screenshot.png") diff --git a/docs/source/examples/linux_display_keyword.py b/docs/source/examples/linux_display_keyword.py index d03341df..bb6c3950 100644 --- a/docs/source/examples/linux_display_keyword.py +++ b/docs/source/examples/linux_display_keyword.py @@ -1,13 +1,11 @@ -""" -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. Usage example with a specific display. """ import mss - with mss.mss(display=":0.0") as sct: for filename in sct.save(): print(filename) diff --git a/docs/source/examples/opencv_numpy.py b/docs/source/examples/opencv_numpy.py index 46e05e03..9275de2b 100644 --- a/docs/source/examples/opencv_numpy.py +++ b/docs/source/examples/opencv_numpy.py @@ -1,6 +1,5 @@ -""" -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. OpenCV/Numpy example. """ @@ -8,9 +7,9 @@ import time import cv2 -import mss -import numpy +import numpy as np +import mss with mss.mss() as sct: # Part of the screen to capture @@ -20,7 +19,7 @@ last_time = time.time() # Get raw pixels from the screen, save it to a Numpy array - img = numpy.array(sct.grab(monitor)) + img = np.array(sct.grab(monitor)) # Display the picture cv2.imshow("OpenCV/Numpy normal", img) @@ -29,7 +28,7 @@ # cv2.imshow('OpenCV/Numpy grayscale', # cv2.cvtColor(img, cv2.COLOR_BGRA2GRAY)) - print("fps: {}".format(1 / (time.time() - last_time))) + print(f"fps: {1 / (time.time() - last_time)}") # Press "q" to quit if cv2.waitKey(25) & 0xFF == ord("q"): diff --git a/docs/source/examples/part_of_screen.py b/docs/source/examples/part_of_screen.py index e4705a58..5ef341dc 100644 --- a/docs/source/examples/part_of_screen.py +++ b/docs/source/examples/part_of_screen.py @@ -1,6 +1,5 @@ -""" -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. Example to capture part of the screen. """ @@ -8,7 +7,6 @@ import mss import mss.tools - with mss.mss() as sct: # The screen part to capture monitor = {"top": 160, "left": 160, "width": 160, "height": 135} diff --git a/docs/source/examples/part_of_screen_monitor_2.py b/docs/source/examples/part_of_screen_monitor_2.py index 9bbc771f..6099f58a 100644 --- a/docs/source/examples/part_of_screen_monitor_2.py +++ b/docs/source/examples/part_of_screen_monitor_2.py @@ -1,6 +1,5 @@ -""" -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. Example to capture part of the screen of the monitor 2. """ @@ -8,7 +7,6 @@ import mss import mss.tools - with mss.mss() as sct: # Get information of monitor 2 monitor_number = 2 diff --git a/docs/source/examples/pil.py b/docs/source/examples/pil.py index 4d8e9729..03ff778c 100644 --- a/docs/source/examples/pil.py +++ b/docs/source/examples/pil.py @@ -1,13 +1,12 @@ -""" -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. PIL example using frombytes(). """ -import mss from PIL import Image +import mss with mss.mss() as sct: # Get rid of the first, as it represents the "All in One" monitor: @@ -21,6 +20,6 @@ # img = Image.frombytes('RGB', sct_img.size, sct_img.rgb) # And save it! - output = "monitor-{}.png".format(num) + output = f"monitor-{num}.png" img.save(output) print(output) diff --git a/docs/source/examples/pil_pixels.py b/docs/source/examples/pil_pixels.py index 11081746..d1264bc6 100644 --- a/docs/source/examples/pil_pixels.py +++ b/docs/source/examples/pil_pixels.py @@ -1,13 +1,12 @@ -""" -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. PIL examples to play with pixels. """ -import mss from PIL import Image +import mss with mss.mss() as sct: # Get a screenshot of the 1st monitor @@ -17,7 +16,7 @@ img = Image.new("RGB", sct_img.size) # Best solution: create a list(tuple(R, G, B), ...) for putdata() - pixels = zip(sct_img.raw[2::4], sct_img.raw[1::4], sct_img.raw[0::4]) + pixels = zip(sct_img.raw[2::4], sct_img.raw[1::4], sct_img.raw[::4]) img.putdata(list(pixels)) # But you can set individual pixels too (slower) diff --git a/docs/source/index.rst b/docs/source/index.rst index def5e870..e0e44719 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 - # The simplest use, save a screen shot of the 1st monitor + # 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.5+** and :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 | @@ -43,3 +51,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 af421925..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.5 and newer + - OS: GNU/Linux, macOS, and Windows + - Python: 3.9 and newer Future @@ -31,3 +31,7 @@ Abandoned - Python 3.2 (2016-10-08) - Python 3.3 (2017-12-05) - Python 3.4 (2018-03-19) +- 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 e1099a3f..4e105a8b 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -9,14 +9,14 @@ So MSS can be used as simply as:: from mss import mss -Or import the good one base on your operating system:: - - # MacOS X - from mss.darwin import MSS as mss +Or import the good one based on your operating system:: # GNU/Linux from mss.linux import MSS as mss + # macOS + from mss.darwin import MSS as mss + # Microsoft Windows from mss.windows import MSS as mss @@ -46,7 +46,7 @@ This is a much better usage, memory efficient:: for _ in range(100): sct.shot() -Also, it is a good thing to save the MSS instance inside an attribute of you class and calling it when needed. +Also, it is a good thing to save the MSS instance inside an attribute of your class and calling it when needed. GNU/Linux @@ -57,10 +57,10 @@ On GNU/Linux, you can specify which display to use (useful for distant screensho with mss(display=":0.0") as sct: # ... -A more specific example to only target GNU/Linux: +A more specific example (only valid on GNU/Linux): .. literalinclude:: examples/linux_display_keyword.py - :lines: 8- + :lines: 9- Command Line @@ -72,6 +72,25 @@ You can use ``mss`` via the CLI:: Or via direct call from Python:: - python -m mss --help + $ 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] + + options: + -h, --help show this help message and exit + -c COORDINATES, --coordinates COORDINATES + the part of the screen to capture: top, left, width, height + -l {0,1,2,3,4,5,6,7,8,9}, --level {0,1,2,3,4,5,6,7,8,9} + the PNG compression level + -m MONITOR, --monitor MONITOR + the monitor to screenshot + -o OUTPUT, --output OUTPUT + the output file name + --with-cursor include the cursor + -q, --quiet do not print created files + -v, --version show program's version number and exit .. versionadded:: 3.1.1 + +.. versionadded:: 8.0.0 + ``--with-cursor`` to include the cursor in screenshots. diff --git a/docs/source/where.rst b/docs/source/where.rst index ab124fb2..a6307d08 100644 --- a/docs/source/where.rst +++ b/docs/source/where.rst @@ -3,46 +3,33 @@ 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. - - -AI, Computer Vison -================== +Do not hesitate to `say Hello! `_ if you are using MSS too. +- `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; - `DoomPy `_ (Autonomous Anti-Demonic Combat Algorithms); - `Europilot `_, a self-driving algorithm using Euro Truck Simulator (ETS2); +- `Flexx Python UI toolkit `_; +- `Go Review Partner `_, a tool to help analyse and review your game of go (weiqi, baduk) using strong bots; +- `Gradient Sampler `_, sample blender gradients from anything on the screen; - `gym-mupen64plus `_, an OpenAI Gym environment wrapper for the Mupen64Plus N64 emulator; +- `NativeShot `_ (Mozilla Firefox module); +- `NCTU Scratch and Python, 2017 Spring `_ (Python course); +- `normcap `_, OCR powered screen-capture tool to capture information instead of images; - `Open Source Self Driving Car Initiative `_; +- `OSRS Bot COLOR (OSBC) `_, a lightweight desktop client for controlling and monitoring color-based automation scripts (bots) for OSRS and private server alternatives; +- `Philips Hue Lights Ambiance `_; +- `Pombo `_, a thief recovery software; +- `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; +- `Stitch `_, a Python Remote Administration Tool (RAT); - `TensorKart `_, a self-driving MarioKart with TensorFlow; +- `videostream_censor `_, a real time video recording censor ; +- `wow-fishing-bot `_, a fishing bot for World of Warcraft that uses template matching from OpenCV; - `Zelda Bowling AI `_; - -Games -===== - -- `Go Review Partner `_, a tool to help analyse and review your game of go (weiqi, baduk) using strong bots; -- `Serpent.AI `_, a Game Agent Framework; - -Learning -======== - -- `NCTU Scratch and Python, 2017 Spring `_ (Python course); - -Security -======== - -- `Automation Framework `_, a Batmans utility; -- `Pombo `_, a thief recovery software; -- `Stitch `_, a Python Remote Administration Tool (RAT); - -Utilities -========= - -- `Flexx Python UI toolkit `_; -- `NativeShot `_ (Mozilla Firefox module); -- `normcap `_, OCR powered screen-capture tool to capture information instead of images; -- `Philips Hue Lights Ambiance `_; -- `ScreenCapLibrary `_, a Robot Framework test library for capturing screenshots and video recording; diff --git a/mss/__init__.py b/mss/__init__.py deleted file mode 100644 index 25b7ab5a..00000000 --- a/mss/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -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 .exception import ScreenShotError -from .factory import mss - -__version__ = "6.1.0" -__author__ = "Mickaël 'Tiger-222' Schoentgen" -__copyright__ = """ - Copyright (c) 2013-2020, Mickaël 'Tiger-222' Schoentgen - - Permission to use, copy, modify, and distribute this software and its - documentation for any purpose and without fee or royalty is hereby - granted, provided that the above copyright notice appear in all copies - and that both that copyright notice and this permission notice appear - in supporting documentation or portions thereof, including - modifications, that you make. -""" -__all__ = ("ScreenShotError", "mss") diff --git a/mss/base.py b/mss/base.py deleted file mode 100644 index ed22b90a..00000000 --- a/mss/base.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -from abc import ABCMeta, abstractmethod -from datetime import datetime -from typing import TYPE_CHECKING -from threading import Lock - -from .exception import ScreenShotError -from .screenshot import ScreenShot -from .tools import to_png - -if TYPE_CHECKING: - # pylint: disable=ungrouped-imports - from typing import Any, Callable, Iterator, List, Optional, Type # noqa - - from .models import Monitor, Monitors # noqa - - -lock = Lock() - - -class MSSBase(metaclass=ABCMeta): - """ This class will be overloaded by a system specific one. """ - - __slots__ = {"_monitors", "cls_image", "compression_level"} - - def __init__(self): - self.cls_image = ScreenShot # type: Type[ScreenShot] - self.compression_level = 6 - self._monitors = [] # type: Monitors - - def __enter__(self): - # type: () -> MSSBase - """ For the cool call `with MSS() as mss:`. """ - - return self - - def __exit__(self, *_): - """ For the cool call `with MSS() as mss:`. """ - - self.close() - - @abstractmethod - def _grab_impl(self, monitor): - # type: (Monitor) -> ScreenShot - """ - Retrieve all pixels from a monitor. Pixels have to be RGB. - That method has to be run using a threading lock. - """ - - @abstractmethod - def _monitors_impl(self): - # type: () -> None - """ - Get positions of monitors (has to be run using a threading lock). - It must populate self._monitors. - """ - - def close(self): - # type: () -> None - """ Clean-up. """ - - def grab(self, monitor): - # type: (Monitor) -> ScreenShot - """ - Retrieve screen pixels for a given monitor. - - Note: *monitor* can be a tuple like PIL.Image.grab() accepts. - - :param monitor: The coordinates and size of the box to capture. - See :meth:`monitors ` for object details. - :return :class:`ScreenShot `. - """ - - # Convert PIL bbox style - if isinstance(monitor, tuple): - monitor = { - "left": monitor[0], - "top": monitor[1], - "width": monitor[2] - monitor[0], - "height": monitor[3] - monitor[1], - } - - with lock: - return self._grab_impl(monitor) - - @property - def monitors(self): - # type: () -> Monitors - """ - Get 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 - } - """ - - if not self._monitors: - with lock: - self._monitors_impl() - - return self._monitors - - def save(self, mon=0, output="monitor-{mon}.png", callback=None): - # type: (int, str, Callable[[str], 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). - """ - - monitors = self.monitors - if not monitors: - raise ScreenShotError("No monitor found.") - - if mon == 0: - # One screen shot by monitor - for idx, monitor in enumerate(monitors[1:], 1): - fname = output.format(mon=idx, date=datetime.now(), **monitor) - if callable(callback): - callback(fname) - sct = self.grab(monitor) - 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. - mon = 0 if mon == -1 else mon - try: - monitor = monitors[mon] - except IndexError: - # pylint: disable=raise-missing-from - raise ScreenShotError("Monitor {!r} does not exist.".format(mon)) - - output = output.format(mon=mon, date=datetime.now(), **monitor) - if callable(callback): - callback(output) - sct = self.grab(monitor) - to_png(sct.rgb, sct.size, level=self.compression_level, output=output) - yield output - - def shot(self, **kwargs): - # type: (Any) -> str - """ - Helper to save the screen shot of the 1st monitor, by default. - You can pass the same arguments as for ``save``. - """ - - kwargs["mon"] = kwargs.get("mon", 1) - return next(self.save(**kwargs)) - - @staticmethod - def _cfactory(attr, func, argtypes, restype, errcheck=None): - # type: (Any, str, List[Any], Any, Optional[Callable]) -> 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 diff --git a/mss/exception.py b/mss/exception.py deleted file mode 100644 index e783175b..00000000 --- a/mss/exception.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Any, Dict # noqa - - -class ScreenShotError(Exception): - """ Error handling class. """ - - def __init__(self, message, details=None): - # type: (str, Dict[str, Any]) -> None - super().__init__(message) - self.details = details or {} diff --git a/mss/factory.py b/mss/factory.py deleted file mode 100644 index 47aea11a..00000000 --- a/mss/factory.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import platform -from typing import TYPE_CHECKING - -from .exception import ScreenShotError - - -if TYPE_CHECKING: - from typing import Any # noqa - - from .base import MSSBase # noqa - - -def mss(**kwargs): - # type: (Any) -> MSSBase - """ Factory returning a proper MSS class instance. - - It detects the plateform we are running on - and choose the most adapted mss_class to take - screenshots. - - It then proxies its arguments to the class for - instantiation. - """ - # pylint: disable=import-outside-toplevel - - os_ = platform.system().lower() - - if os_ == "darwin": - from . import darwin - - return darwin.MSS(**kwargs) - - if os_ == "linux": - from . import linux - - return linux.MSS(**kwargs) - - if os_ == "windows": - from . import windows - - return windows.MSS(**kwargs) - - raise ScreenShotError("System {!r} not (yet?) implemented.".format(os_)) diff --git a/mss/linux.py b/mss/linux.py deleted file mode 100644 index 8e56c395..00000000 --- a/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 -""" - -import ctypes -import ctypes.util -import os -import threading -from ctypes import ( - POINTER, - CFUNCTYPE, - Structure, - c_char_p, - c_int, - c_int32, - c_long, - c_ubyte, - c_uint, - c_uint32, - c_ulong, - c_ushort, - c_void_p, -) -from types import SimpleNamespace -from typing import TYPE_CHECKING - -from .base import MSSBase, lock -from .exception import ScreenShotError - -if TYPE_CHECKING: - from typing import Any, Dict, List, Optional, Tuple, Union # noqa - - from .models import Monitor, Monitors # noqa - from .screenshot import ScreenShot # noqa - - -__all__ = ("MSS",) - - -ERROR = SimpleNamespace(details=None) -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. - """ - - -class Event(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)), - ("serial", c_ulong), - ("error_code", c_ubyte), - ("request_code", c_ubyte), - ("minor_code", c_ubyte), - ("resourceid", c_void_p), - ] - - -class XWindowAttributes(Structure): - """ Attributes for the specified window. """ - - _fields_ = [ - ("x", c_int32), - ("y", c_int32), - ("width", c_int32), - ("height", c_int32), - ("border_width", c_int32), - ("depth", c_int32), - ("visual", c_ulong), - ("root", c_ulong), - ("class", c_int32), - ("bit_gravity", c_int32), - ("win_gravity", c_int32), - ("backing_store", c_int32), - ("backing_planes", c_ulong), - ("backing_pixel", c_ulong), - ("save_under", c_int32), - ("colourmap", c_ulong), - ("mapinstalled", c_uint32), - ("map_state", c_uint32), - ("all_event_masks", c_ulong), - ("your_event_mask", c_ulong), - ("do_not_propagate_mask", c_ulong), - ("override_redirect", c_int32), - ("screen", c_ulong), - ] - - -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), - ("height", c_int), - ("xoffset", c_int), - ("format", c_int), - ("data", c_void_p), - ("byte_order", c_int), - ("bitmap_unit", c_int), - ("bitmap_bit_order", c_int), - ("bitmap_pad", c_int), - ("depth", c_int), - ("bytes_per_line", c_int), - ("bits_per_pixel", c_int), - ("red_mask", c_ulong), - ("green_mask", c_ulong), - ("blue_mask", c_ulong), - ] - - -class XRRModeInfo(Structure): - """ Voilà, voilà. """ - - -class XRRScreenResources(Structure): - """ - Structure that contains arrays of XIDs that point to the - available outputs and associated CRTCs. - """ - - _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 XRRCrtcInfo(Structure): - """ Structure that contains CRTC information. """ - - _fields_ = [ - ("timestamp", c_ulong), - ("x", c_int), - ("y", c_int), - ("width", c_int), - ("height", c_int), - ("mode", c_long), - ("rotation", c_int), - ("noutput", c_int), - ("outputs", POINTER(c_long)), - ("rotations", c_ushort), - ("npossible", c_int), - ("possible", POINTER(c_long)), - ] - - -@CFUNCTYPE(c_int, POINTER(Display), POINTER(Event)) -def error_handler(_, event): - # type: (Any, Any) -> int - """ Specifies the program's supplied error handler. """ - - evt = event.contents - ERROR.details = { - "type": evt.type, - "serial": evt.serial, - "error_code": evt.error_code, - "request_code": evt.request_code, - "minor_code": evt.minor_code, - } - return 0 - - -def validate(retval, func, args): - # type: (int, Any, Tuple[Any, Any]) -> Optional[Tuple[Any, Any]] - """ Validate the returned value of a Xlib or XRANDR function. """ - - if retval != 0 and not ERROR.details: - return args - - err = "{}() failed".format(func.__name__) - details = {"retval": retval, "args": args} - raise ScreenShotError(err, details=details) - - -# C functions that will be initialised later. -# See https://tronche.com/gui/x/xlib/function-index.html for details. -# -# This is a dict: -# cfunction: (attr, argtypes, restype) -# -# Available attr: xlib, xrandr. -# -# Note: keep it sorted by cfunction. -CFUNCTIONS = { - "XDefaultRootWindow": ("xlib", [POINTER(Display)], POINTER(XWindowAttributes)), - "XDestroyImage": ("xlib", [POINTER(XImage)], c_void_p), - "XGetErrorText": ("xlib", [POINTER(Display), c_int, c_char_p, c_int], c_void_p), - "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_int), -} - - -class MSS(MSSBase): - """ - Multiple ScreenShots implementation for GNU/Linux. - It uses intensively the Xlib and its Xrandr extension. - """ - - __slots__ = {"drawable", "root", "xlib", "xrandr"} - - # A dict to maintain *display* values created by multiple threads. - _display_dict = {} # type: Dict[threading.Thread, int] - - def __init__(self, display=None): - # type: (Optional[Union[bytes, str]]) -> None - """ GNU/Linux initialisations. """ - - super().__init__() - - if not display: - try: - display = os.environ["DISPLAY"].encode("utf-8") - except KeyError: - # pylint: disable=raise-missing-from - raise ScreenShotError("$DISPLAY not set.") - - if not isinstance(display, bytes): - display = display.encode("utf-8") - - if b":" not in display: - raise ScreenShotError("Bad display value: {!r}.".format(display)) - - x11 = ctypes.util.find_library("X11") - if not x11: - raise ScreenShotError("No X11 library found.") - self.xlib = ctypes.cdll.LoadLibrary(x11) - - # Install the error handler to prevent interpreter crashes: - # any error will raise a ScreenShotError exception. - self.xlib.XSetErrorHandler(error_handler) - - xrandr = ctypes.util.find_library("Xrandr") - if not xrandr: - raise ScreenShotError("No Xrandr extension found.") - self.xrandr = ctypes.cdll.LoadLibrary(xrandr) - - self._set_cfunctions() - - self.root = self.xlib.XDefaultRootWindow(self._get_display(display)) - - if not self.has_extension("RANDR"): - raise ScreenShotError("No Xrandr extension found.") - - # Fix for XRRGetScreenResources and XGetImage: - # expected LP_Display instance instead of LP_XWindowAttributes - self.drawable = ctypes.cast(self.root, POINTER(Display)) - - def has_extension(self, extension): - # type: (str) -> bool - """Return True if the given *extension* is part of the extensions list of the server.""" - with lock: - major_opcode_return = c_int() - first_event_return = c_int() - first_error_return = c_int() - - try: - self.xlib.XQueryExtension( - self._get_display(), - extension.encode("latin1"), - ctypes.byref(major_opcode_return), - ctypes.byref(first_event_return), - ctypes.byref(first_error_return), - ) - except ScreenShotError: - return False - else: - return True - - def _get_display(self, disp=None): - """ - Retrieve a thread-safe display from XOpenDisplay(). - In multithreading, if the thread who creates *display* is dead, *display* will - no longer be valid to grab the screen. The *display* attribute is replaced - with *_display_dict* to maintain the *display* values in multithreading. - Since the current thread and main thread are always alive, reuse their - *display* value first. - """ - cur_thread, main_thread = threading.current_thread(), threading.main_thread() - display = MSS._display_dict.get(cur_thread) or MSS._display_dict.get( - main_thread - ) - if not display: - display = MSS._display_dict[cur_thread] = self.xlib.XOpenDisplay(disp) - return display - - def _set_cfunctions(self): - """ Set all ctypes functions and attach them to attributes. """ - - cfactory = self._cfactory - attrs = { - "xlib": self.xlib, - "xrandr": self.xrandr, - } - for func, (attr, argtypes, restype) in CFUNCTIONS.items(): - try: - cfactory( - attr=attrs[attr], - errcheck=validate, - func=func, - argtypes=argtypes, - restype=restype, - ) # type: ignore - except AttributeError: - pass - - def get_error_details(self): - # type: () -> Optional[Dict[str, Any]] - """ Get more information about the latest X server error. """ - - details = {} # type: Dict[str, Any] - - if ERROR.details: - details = {"xerror_details": ERROR.details} - ERROR.details = None - xserver_error = ctypes.create_string_buffer(1024) - self.xlib.XGetErrorText( - self._get_display(), - details.get("xerror_details", {}).get("error_code", 0), - xserver_error, - len(xserver_error), - ) - xerror = xserver_error.value.decode("utf-8") - if xerror != "0": - details["xerror"] = xerror - - return details - - def _monitors_impl(self): - # type: () -> None - """ Get positions of monitors. It will populate self._monitors. """ - - display = self._get_display() - int_ = int - xrandr = self.xrandr - - # All monitors - gwa = XWindowAttributes() - self.xlib.XGetWindowAttributes(display, self.root, ctypes.byref(gwa)) - self._monitors.append( - { - "left": int_(gwa.x), - "top": int_(gwa.y), - "width": int_(gwa.width), - "height": int_(gwa.height), - } - ) - - # Each monitors - # 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.drawable).contents - except AttributeError: - mon = xrandr.XRRGetScreenResources(display, self.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): - # type: (Monitor) -> ScreenShot - """ Retrieve all pixels from a monitor. Pixels have to be RGB. """ - - ximage = self.xlib.XGetImage( - self._get_display(), - self.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: - raise ScreenShotError( - "[XImage] bits per pixel value not (yet?) implemented: {}.".format( - bits_per_pixel - ) - ) - - raw_data = ctypes.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) diff --git a/mss/models.py b/mss/models.py deleted file mode 100644 index fe5b6063..00000000 --- a/mss/models.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import collections -from typing import Dict, List, Tuple - - -Monitor = Dict[str, int] -Monitors = List[Monitor] - -Pixel = Tuple[int, int, int] -Pixels = List[Pixel] - -Pos = collections.namedtuple("Pos", "left, top") -Size = collections.namedtuple("Size", "width, height") diff --git a/mss/screenshot.py b/mss/screenshot.py deleted file mode 100644 index 6ed6e9ba..00000000 --- a/mss/screenshot.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -from typing import TYPE_CHECKING - -from .models import Size, Pos -from .exception import ScreenShotError - -if TYPE_CHECKING: - from typing import Any, Dict, Iterator, Optional # noqa - - from .models import Monitor, Pixel, Pixels # noqa - - -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*. - """ - - __slots__ = {"__pixels", "__rgb", "pos", "raw", "size"} - - def __init__(self, data, monitor, size=None): - # type: (bytearray, Monitor, Optional[Size]) -> None - - self.__pixels = None # type: Optional[Pixels] - self.__rgb = None # type: Optional[bytes] - - #: Bytearray of the raw BGRA pixels retrieved by ctypes - #: OS independent implementations. - self.raw = data - - #: NamedTuple of the screen shot coordinates. - self.pos = Pos(monitor["left"], monitor["top"]) - - if size is not None: - #: NamedTuple of the screen shot size. - self.size = size - else: - self.size = Size(monitor["width"], monitor["height"]) - - def __repr__(self): - return ("<{!s} pos={cls.left},{cls.top} size={cls.width}x{cls.height}>").format( - type(self).__name__, cls=self - ) - - @property - def __array_interface__(self): - # type: () -> Dict[str, Any] - """ - Numpy array interface support. - It uses raw data in BGRA form. - - See https://docs.scipy.org/doc/numpy/reference/arrays.interface.html - """ - - return { - "version": 3, - "shape": (self.height, self.width, 4), - "typestr": "|u1", - "data": self.raw, - } - - @classmethod - def from_size(cls, data, width, height): - # type: (bytearray, int, int) -> ScreenShot - """ Instantiate a new class given only screen shot's data and size. """ - - monitor = {"left": 0, "top": 0, "width": width, "height": height} - return cls(data, monitor) - - @property - def bgra(self): - # type: () -> bytes - """ BGRA values from the BGRA raw pixels. """ - return bytes(self.raw) - - @property - def height(self): - # type: () -> int - """ Convenient accessor to the height size. """ - return self.size.height - - @property - def left(self): - # type: () -> int - """ Convenient accessor to the left position. """ - return self.pos.left - - @property - def pixels(self): - # type: () -> Pixels - """ - :return list: RGB tuples. - """ - - if not self.__pixels: - rgb_tuples = zip( - self.raw[2::4], self.raw[1::4], self.raw[0::4] - ) # type: Iterator[Pixel] - self.__pixels = list(zip(*[iter(rgb_tuples)] * self.width)) # type: ignore - - return self.__pixels - - @property - def rgb(self): - # type: () -> bytes - """ - Compute RGB values from the BGRA raw pixels. - - :return bytes: RGB pixels. - """ - - if not self.__rgb: - rgb = bytearray(self.height * self.width * 3) - raw = self.raw - rgb[0::3] = raw[2::4] - rgb[1::3] = raw[1::4] - rgb[2::3] = raw[0::4] - self.__rgb = bytes(rgb) - - return self.__rgb - - @property - def top(self): - # type: () -> int - """ Convenient accessor to the top position. """ - return self.pos.top - - @property - def width(self): - # type: () -> int - """ Convenient accessor to the width size. """ - return self.size.width - - def pixel(self, coord_x, coord_y): - # type: (int, 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] # type: ignore - except IndexError: - # pylint: disable=raise-missing-from - raise ScreenShotError( - "Pixel location ({}, {}) is out of range.".format(coord_x, coord_y) - ) diff --git a/mss/tests/conftest.py b/mss/tests/conftest.py deleted file mode 100644 index f32869e5..00000000 --- a/mss/tests/conftest.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import glob -import os - -import pytest -import mss - - -def purge_files(): - """ Remove all generated files from previous runs. """ - - for fname in glob.glob("*.png"): - print("Deleting {!r} ...".format(fname)) - os.unlink(fname) - - for fname in glob.glob("*.png.old"): - print("Deleting {!r} ...".format(fname)) - os.unlink(fname) - - -@pytest.fixture(scope="module", autouse=True) -def before_tests(request): - request.addfinalizer(purge_files) - - -@pytest.fixture(scope="module") -def sct(): - try: - # `display` kwarg is only for GNU/Linux - return mss.mss(display=os.getenv("DISPLAY")) - except TypeError: - return mss.mss() - - -@pytest.fixture(scope="session") -def is_travis(): - return "TRAVIS" in os.environ - - -@pytest.fixture(scope="session") -def raw(): - here = os.path.dirname(__file__) - file = os.path.join(here, "res", "monitor-1024x768.raw") - with open(file, "rb") as f: - yield f.read() - - -@pytest.fixture(scope="module") -def pixel_ratio(sct): - """Get the pixel, used to adapt test checks.""" - # Grab a 1x1 screenshot - region = {"top": 0, "left": 0, "width": 1, "height": 1} - - # On macOS with Retina display,the width will be 2 instead of 1 - pixel_size = sct.grab(region).size[0] - - return pixel_size diff --git a/mss/tests/res/monitor-1024x768.raw b/mss/tests/res/monitor-1024x768.raw deleted file mode 100644 index 65a1c720..00000000 Binary files a/mss/tests/res/monitor-1024x768.raw and /dev/null differ diff --git a/mss/tests/test_cls_image.py b/mss/tests/test_cls_image.py deleted file mode 100644 index a3b198cc..00000000 --- a/mss/tests/test_cls_image.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - - -class SimpleScreenShot: - def __init__(self, data, monitor, **kwargs): - self.raw = bytes(data) - self.monitor = monitor - - -def test_custom_cls_image(sct): - sct.cls_image = SimpleScreenShot - mon1 = sct.monitors[1] - image = sct.grab(mon1) - assert isinstance(image, SimpleScreenShot) - assert isinstance(image.raw, bytes) - assert isinstance(image.monitor, dict) diff --git a/mss/tests/test_find_monitors.py b/mss/tests/test_find_monitors.py deleted file mode 100644 index c5b15695..00000000 --- a/mss/tests/test_find_monitors.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - - -def test_get_monitors(sct): - assert sct.monitors - - -def test_keys_aio(sct): - all_monitors = sct.monitors[0] - assert "top" in all_monitors - assert "left" in all_monitors - assert "height" in all_monitors - assert "width" in all_monitors - - -def test_keys_monitor_1(sct): - mon1 = sct.monitors[1] - assert "top" in mon1 - assert "left" in mon1 - assert "height" in mon1 - assert "width" in mon1 - - -def test_dimensions(sct, is_travis): - mon = sct.monitors[1] - if is_travis: - assert mon["width"] == 1280 - assert mon["height"] == 1240 - else: - assert mon["width"] > 0 - assert mon["height"] > 0 diff --git a/mss/tests/test_get_pixels.py b/mss/tests/test_get_pixels.py deleted file mode 100644 index e340e5cf..00000000 --- a/mss/tests/test_get_pixels.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import pytest -from mss.base import ScreenShot -from mss.exception import ScreenShotError - - -def test_grab_monitor(sct): - for mon in sct.monitors: - image = sct.grab(mon) - assert isinstance(image, ScreenShot) - assert isinstance(image.raw, bytearray) - assert isinstance(image.rgb, bytes) - - -def test_grab_part_of_screen(sct, pixel_ratio): - monitor = {"top": 160, "left": 160, "width": 160, "height": 160} - image = sct.grab(monitor) - assert isinstance(image, ScreenShot) - assert isinstance(image.raw, bytearray) - assert isinstance(image.rgb, bytes) - assert image.top == 160 - assert image.left == 160 - assert image.width == 160 * pixel_ratio - assert image.height == 160 * pixel_ratio - - -def test_grab_part_of_screen_rounded(sct, pixel_ratio): - monitor = {"top": 160, "left": 160, "width": 161, "height": 159} - image = sct.grab(monitor) - assert isinstance(image, ScreenShot) - assert isinstance(image.raw, bytearray) - assert isinstance(image.rgb, bytes) - assert image.top == 160 - assert image.left == 160 - assert image.width == 161 * pixel_ratio - assert image.height == 159 * pixel_ratio - - -def test_grab_individual_pixels(sct): - monitor = {"top": 160, "left": 160, "width": 222, "height": 42} - image = sct.grab(monitor) - assert isinstance(image.pixel(0, 0), tuple) - with pytest.raises(ScreenShotError): - image.pixel(image.width + 1, 12) diff --git a/mss/tests/test_gnu_linux.py b/mss/tests/test_gnu_linux.py deleted file mode 100644 index 10119760..00000000 --- a/mss/tests/test_gnu_linux.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import ctypes.util -import os -import platform - -import mss -import pytest -from mss.base import MSSBase -from mss.exception import ScreenShotError - - -if platform.system().lower() != "linux": - pytestmark = pytest.mark.skip - - -PYPY = platform.python_implementation() == "PyPy" - - -@pytest.mark.skipif(PYPY, reason="Failure on PyPy") -def test_factory_systems(monkeypatch): - """ - Here, we are testing all systems. - - Too hard to maintain the test for all platforms, - so test only on GNU/Linux. - """ - - # GNU/Linux - monkeypatch.setattr(platform, "system", lambda: "LINUX") - with mss.mss() as sct: - assert isinstance(sct, MSSBase) - monkeypatch.undo() - - # macOS - monkeypatch.setattr(platform, "system", lambda: "Darwin") - with pytest.raises((ScreenShotError, ValueError)): - # ValueError on macOS Big Sur - mss.mss() - monkeypatch.undo() - - # Windows - monkeypatch.setattr(platform, "system", lambda: "wInDoWs") - with pytest.raises(ImportError): - # ImportError: cannot import name 'WINFUNCTYPE' - mss.mss() - - -def test_arg_display(monkeypatch): - import mss - - # Good value - display = os.getenv("DISPLAY") - with mss.mss(display=display): - pass - - # Bad `display` (missing ":" in front of the number) - with pytest.raises(ScreenShotError): - with mss.mss(display="0"): - pass - - # No `DISPLAY` in envars - monkeypatch.delenv("DISPLAY") - with pytest.raises(ScreenShotError): - with mss.mss(): - pass - - -@pytest.mark.skipif(PYPY, reason="Failure on PyPy") -def test_bad_display_structure(monkeypatch): - import mss.linux - - monkeypatch.setattr(mss.linux, "Display", lambda: None) - with pytest.raises(TypeError): - with mss.mss(): - pass - - -def test_no_xlib_library(monkeypatch): - monkeypatch.setattr(ctypes.util, "find_library", lambda x: None) - with pytest.raises(ScreenShotError): - with mss.mss(): - pass - - -def test_no_xrandr_extension(monkeypatch): - x11 = ctypes.util.find_library("X11") - - def find_lib_mocked(lib): - """ - Returns None to emulate no XRANDR library. - Returns the previous found X11 library else. - - It is a naive approach, but works for now. - """ - - if lib == "Xrandr": - return None - return x11 - - # No `Xrandr` library - monkeypatch.setattr(ctypes.util, "find_library", find_lib_mocked) - with pytest.raises(ScreenShotError): - with mss.mss(): - pass - - -def test_region_out_of_monitor_bounds(): - display = os.getenv("DISPLAY") - with mss.mss(display=display) as sct: - with pytest.raises(ScreenShotError) as exc: - monitor = {"left": -30, "top": 0, "width": 100, "height": 100} - assert sct.grab(monitor) - - assert str(exc.value) - assert "retval" in exc.value.details - assert "args" in exc.value.details - - details = sct.get_error_details() - assert details["xerror"] - assert isinstance(details["xerror_details"], dict) - - -def test_has_extension(): - display = os.getenv("DISPLAY") - with mss.mss(display=display) as sct: - assert sct.has_extension("RANDR") - assert not sct.has_extension("NOEXT") diff --git a/mss/tests/test_implementation.py b/mss/tests/test_implementation.py deleted file mode 100644 index ba788ef5..00000000 --- a/mss/tests/test_implementation.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import os -import os.path -import platform - -import pytest -import mss -import mss.tools -from mss.base import MSSBase -from mss.exception import ScreenShotError -from mss.screenshot import ScreenShot - - -class MSS0(MSSBase): - """ Nothing implemented. """ - - pass - - -class MSS1(MSSBase): - """ Only `grab()` implemented. """ - - def grab(self, monitor): - pass - - -class MSS2(MSSBase): - """ Only `monitor` implemented. """ - - @property - def monitors(self): - return [] - - -@pytest.mark.parametrize("cls", [MSS0, MSS1, MSS2]) -def test_incomplete_class(cls): - with pytest.raises(TypeError): - cls() - - -def test_bad_monitor(sct): - with pytest.raises(ScreenShotError): - sct.grab(sct.shot(mon=222)) - - -def test_repr(sct, pixel_ratio): - box = {"top": 0, "left": 0, "width": 10, "height": 10} - expected_box = { - "top": 0, - "left": 0, - "width": 10 * pixel_ratio, - "height": 10 * pixel_ratio, - } - img = sct.grab(box) - ref = ScreenShot(bytearray(b"42"), expected_box) - assert repr(img) == repr(ref) - - -def test_factory(monkeypatch): - # Current system - with mss.mss() as sct: - assert isinstance(sct, MSSBase) - - # Unknown - monkeypatch.setattr(platform, "system", lambda: "Chuck Norris") - with pytest.raises(ScreenShotError) as exc: - mss.mss() - monkeypatch.undo() - - error = exc.value.args[0] - assert error == "System 'chuck norris' not (yet?) implemented." - - -def test_entry_point(capsys, sct): - from mss.__main__ import main - from datetime import datetime - - for opt in ("-m", "--monitor"): - main([opt, "1"]) - out, _ = capsys.readouterr() - assert out.endswith("monitor-1.png\n") - assert os.path.isfile("monitor-1.png") - os.remove("monitor-1.png") - - for opt in zip(("-m 1", "--monitor=1"), ("-q", "--quiet")): - main(opt) - out, _ = capsys.readouterr() - assert not out - assert os.path.isfile("monitor-1.png") - os.remove("monitor-1.png") - - fmt = "sct-{width}x{height}.png" - for opt in ("-o", "--out"): - main([opt, fmt]) - out, _ = capsys.readouterr() - for monitor, line in zip(sct.monitors[1:], out.splitlines()): - filename = fmt.format(**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()) - out, _ = capsys.readouterr() - assert out.endswith(filename + "\n") - assert os.path.isfile(filename) - os.remove(filename) - - coordinates = "2,12,40,67" - for opt in ("-c", "--coordinates"): - main([opt, coordinates]) - filename = "sct-2x12_40x67.png" - out, _ = capsys.readouterr() - assert out.endswith(filename + "\n") - assert os.path.isfile(filename) - os.remove(filename) - - coordinates = "2,12,40" - for opt in ("-c", "--coordinates"): - main([opt, coordinates]) - out, _ = capsys.readouterr() - assert out == "Coordinates syntax: top, left, width, height\n" - - -def test_grab_with_tuple(sct, pixel_ratio): - left = 100 - top = 100 - right = 500 - lower = 500 - width = right - left # 400px width - height = lower - top # 400px height - - # PIL like - box = (left, top, right, lower) - im = sct.grab(box) - assert im.size == (width * pixel_ratio, height * pixel_ratio) - - # MSS like - box2 = {"left": left, "top": top, "width": width, "height": height} - im2 = sct.grab(box2) - assert im.size == im2.size - assert im.pos == im2.pos - assert im.rgb == im2.rgb - - -def test_grab_with_tuple_percents(sct, pixel_ratio): - 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 - right = left + 500 # 500px - lower = top + 500 # 500px - width = right - left - height = lower - top - - # PIL like - box = (left, top, right, lower) - im = sct.grab(box) - assert im.size == (width * pixel_ratio, height * pixel_ratio) - - # MSS like - box2 = {"left": left, "top": top, "width": width, "height": height} - im2 = sct.grab(box2) - assert im.size == im2.size - assert im.pos == im2.pos - assert im.rgb == im2.rgb - - -def test_thread_safety(): - """Regression test for issue #169.""" - import threading - import time - - def record(check): - """Record for one second.""" - - start_time = time.time() - while time.time() - start_time < 1: - with mss.mss() as sct: - sct.grab(sct.monitors[1]) - - check[threading.current_thread()] = True - - checkpoint = {} - t1 = threading.Thread(target=record, args=(checkpoint,)) - t2 = threading.Thread(target=record, args=(checkpoint,)) - - t1.start() - time.sleep(0.5) - t2.start() - - t1.join() - t2.join() - - assert len(checkpoint) == 2 diff --git a/mss/tests/test_leaks.py b/mss/tests/test_leaks.py deleted file mode 100644 index e7a6ca1b..00000000 --- a/mss/tests/test_leaks.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import os -import platform -from typing import TYPE_CHECKING - -import pytest -from mss import mss - -if TYPE_CHECKING: - from typing import Callable # noqa - - -OS = platform.system().lower() -PID = os.getpid() - - -def get_opened_socket(): - # type: () -> 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 = "lsof -U | grep {}".format(PID) - output = subprocess.check_output(cmd, shell=True) - return len(output.splitlines()) - - -def get_handles(): - # type: () -> 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 - - PQI = 0x400 # PROCESS_QUERY_INFORMATION - GR_GDIOBJECTS = 0 - h = ctypes.windll.kernel32.OpenProcess(PQI, 0, PID) - return ctypes.windll.user32.GetGuiResources(h, GR_GDIOBJECTS) - - -@pytest.fixture -def monitor_func(): - # type: () -> Callable[[], int] - """ OS specific function to check resources in use. """ - - if OS == "linux": - return get_opened_socket - - return get_handles - - -def bound_instance_without_cm(): - sct = mss() - sct.shot() - - -def bound_instance_without_cm_but_use_close(): - sct = mss() - sct.shot() - sct.close() - # Calling .close() twice should be possible - sct.close() - - -def unbound_instance_without_cm(): - mss().shot() - - -def with_context_manager(): - with mss() as sct: - sct.shot() - - -def regression_issue_128(): - """Regression test for issue #128: areas overlap.""" - with mss() 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(): - """Regression test for issue #135: multiple areas.""" - with mss() 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} - sct.grab(bounding_box_test) - bounding_box_score = {"top": 110, "left": 110, "width": 100, "height": 100} - sct.grab(bounding_box_score) - - -@pytest.mark.skipif(OS == "darwin", reason="No possible leak on macOS.") -@pytest.mark.parametrize( - "func", - ( - bound_instance_without_cm, - bound_instance_without_cm_but_use_close, - unbound_instance_without_cm, - with_context_manager, - regression_issue_128, - regression_issue_135, - ), -) -def test_resource_leaks(func, monitor_func): - """ Check for resource leaks with different use cases. """ - - # Warm-up - func() - - original_resources = monitor_func() - allocated_resources = 0 - - for _ in range(5): - func() - new_resources = monitor_func() - allocated_resources = max(allocated_resources, new_resources) - - assert original_resources == allocated_resources diff --git a/mss/tests/test_macos.py b/mss/tests/test_macos.py deleted file mode 100644 index 1cf4e4c8..00000000 --- a/mss/tests/test_macos.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import ctypes.util -import platform - -import mss -import pytest -from mss.exception import ScreenShotError - - -if platform.system().lower() != "darwin": - pytestmark = pytest.mark.skip - - -def test_repr(): - from mss.darwin import CGSize, CGPoint, CGRect - - # CGPoint - point = CGPoint(2.0, 1.0) - ref = CGPoint() - ref.x = 2.0 - ref.y = 1.0 - assert repr(point) == repr(ref) - - # CGSize - size = CGSize(2.0, 1.0) - ref = CGSize() - ref.width = 2.0 - ref.height = 1.0 - assert repr(size) == repr(ref) - - # CGRect - rect = CGRect(point, size) - ref = CGRect() - ref.origin.x = 2.0 - ref.origin.y = 1.0 - ref.size.width = 2.0 - ref.size.height = 1.0 - assert repr(rect) == repr(ref) - - -def test_implementation(monkeypatch): - # No `CoreGraphics` library - version = float(".".join(platform.mac_ver()[0].split(".")[:2])) - - if version < 10.16: - monkeypatch.setattr(ctypes.util, "find_library", lambda x: None) - with pytest.raises(ScreenShotError): - mss.mss() - monkeypatch.undo() - - with mss.mss() as sct: - # Test monitor's rotation - original = sct.monitors[1] - monkeypatch.setattr(sct.core, "CGDisplayRotation", lambda x: -90.0) - sct._monitors = [] - 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 *args: None) - with pytest.raises(ScreenShotError): - sct.grab(sct.monitors[1]) diff --git a/mss/tests/test_save.py b/mss/tests/test_save.py deleted file mode 100644 index bc4fbb28..00000000 --- a/mss/tests/test_save.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import os.path -from datetime import datetime - -import pytest - - -def test_at_least_2_monitors(sct): - shots = list(sct.save(mon=0)) - assert len(shots) >= 1 - - -def test_files_exist(sct): - for filename in sct.save(): - assert os.path.isfile(filename) - - assert os.path.isfile(sct.shot()) - - sct.shot(mon=-1, output="fullscreen.png") - assert os.path.isfile("fullscreen.png") - - -def test_callback(sct): - def on_exists(fname): - if os.path.isfile(fname): - new_file = fname + ".old" - os.rename(fname, new_file) - - filename = sct.shot(mon=0, output="mon0.png", callback=on_exists) - assert os.path.isfile(filename) - - filename = sct.shot(output="mon1.png", callback=on_exists) - assert os.path.isfile(filename) - - -def test_output_format_simple(sct): - filename = sct.shot(mon=1, output="mon-{mon}.png") - assert filename == "mon-1.png" - assert os.path.isfile(filename) - - -def test_output_format_positions_and_sizes(sct): - fmt = "sct-{top}x{left}_{width}x{height}.png" - filename = sct.shot(mon=1, output=fmt) - assert filename == fmt.format(**sct.monitors[1]) - assert os.path.isfile(filename) - - -def test_output_format_date_simple(sct): - fmt = "sct_{mon}-{date}.png" - try: - filename = sct.shot(mon=1, output=fmt) - except IOError: - # [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.") - else: - assert os.path.isfile(filename) - - -def test_output_format_date_custom(sct): - fmt = "sct_{date:%Y-%m-%d}.png" - filename = sct.shot(mon=1, output=fmt) - assert filename == fmt.format(date=datetime.now()) - assert os.path.isfile(filename) diff --git a/mss/tests/test_setup.py b/mss/tests/test_setup.py deleted file mode 100644 index e1a213c4..00000000 --- a/mss/tests/test_setup.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -from subprocess import check_output - -from mss import __version__ - -CMD = "python setup.py sdist bdist_wheel".split() - - -def test_wheel_python_3_only(): - """Ensure the produced wheel is Python 3 only.""" - output = str(check_output(CMD)) - text = "mss-{}-py3-none-any.whl".format(__version__) - assert text in output diff --git a/mss/tests/test_third_party.py b/mss/tests/test_third_party.py deleted file mode 100644 index e562bf3d..00000000 --- a/mss/tests/test_third_party.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import os -import os.path - -import pytest - - -try: - import numpy -except (ImportError, RuntimeError): - # RuntimeError on Python 3.9 (macOS): Polyfit sanity test emitted a warning, ... - numpy = None - -try: - from PIL import Image -except ImportError: - Image = None - - -@pytest.mark.skipif(numpy is None, reason="Numpy module not available.") -def test_numpy(sct, pixel_ratio): - box = {"top": 0, "left": 0, "width": 10, "height": 10} - img = numpy.array(sct.grab(box)) - assert len(img) == 10 * pixel_ratio - - -@pytest.mark.skipif(Image is None, reason="PIL module not available.") -def test_pil(sct): - width, height = 16, 16 - box = {"top": 0, "left": 0, "width": width, "height": height} - sct_img = sct.grab(box) - - img = Image.frombytes("RGB", sct_img.size, sct_img.rgb) - assert img.mode == "RGB" - assert img.size == sct_img.size - - for x in range(width): - for y in range(height): - assert img.getpixel((x, y)) == sct_img.pixel(x, y) - - img.save("box.png") - assert os.path.isfile("box.png") - - -@pytest.mark.skipif(Image is None, reason="PIL module not available.") -def test_pil_bgra(sct): - width, height = 16, 16 - box = {"top": 0, "left": 0, "width": width, "height": height} - sct_img = sct.grab(box) - - img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") - assert img.mode == "RGB" - assert img.size == sct_img.size - - for x in range(width): - for y in range(height): - assert img.getpixel((x, y)) == sct_img.pixel(x, y) - - img.save("box-bgra.png") - assert os.path.isfile("box-bgra.png") - - -@pytest.mark.skipif(Image is None, reason="PIL module not available.") -def test_pil_not_16_rounded(sct): - width, height = 10, 10 - box = {"top": 0, "left": 0, "width": width, "height": height} - sct_img = sct.grab(box) - - img = Image.frombytes("RGB", sct_img.size, sct_img.rgb) - assert img.mode == "RGB" - assert img.size == sct_img.size - - for x in range(width): - for y in range(height): - assert img.getpixel((x, y)) == sct_img.pixel(x, y) - - img.save("box.png") - assert os.path.isfile("box.png") diff --git a/mss/tests/test_tools.py b/mss/tests/test_tools.py deleted file mode 100644 index ecdea05d..00000000 --- a/mss/tests/test_tools.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import hashlib -import os.path -import zlib - -import pytest -from mss.tools import to_png - - -WIDTH = 10 -HEIGHT = 10 -MD5SUM = "055e615b74167c9bdfea16a00539450c" - - -def test_bad_compression_level(sct): - sct.compression_level = 42 - try: - with pytest.raises(zlib.error): - sct.shot() - finally: - sct.compression_level = 6 - - -def test_compression_level(sct): - data = b"rgb" * WIDTH * HEIGHT - output = "{}x{}.png".format(WIDTH, HEIGHT) - - 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, checksum): - data = b"rgb" * WIDTH * HEIGHT - raw = to_png(data, (WIDTH, HEIGHT), level=level) - md5 = hashlib.md5(raw).hexdigest() - assert md5 == checksum - - -def test_output_file(): - data = b"rgb" * WIDTH * HEIGHT - output = "{}x{}.png".format(WIDTH, HEIGHT) - 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 - - -def test_output_raw_bytes(): - data = b"rgb" * WIDTH * HEIGHT - raw = to_png(data, (WIDTH, HEIGHT)) - assert hashlib.md5(raw).hexdigest() == MD5SUM diff --git a/mss/tests/test_windows.py b/mss/tests/test_windows.py deleted file mode 100644 index 62f7b6c2..00000000 --- a/mss/tests/test_windows.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import platform -import threading - -import mss -import pytest -from mss.exception import ScreenShotError - - -if platform.system().lower() != "windows": - pytestmark = pytest.mark.skip - - -def test_implementation(monkeypatch): - # Test bad data retrieval - with mss.mss() as sct: - monkeypatch.setattr(sct.gdi32, "GetDIBits", lambda *args: 0) - with pytest.raises(ScreenShotError): - sct.shot() - - -def test_region_caching(): - """The region to grab is cached, ensure this is well-done.""" - from mss.windows import MSS - - with mss.mss() as sct: - # Reset the current BMP - if MSS.bmp: - sct.gdi32.DeleteObject(MSS.bmp) - MSS.bmp = None - - # Grab the area 1 - region1 = {"top": 0, "left": 0, "width": 200, "height": 200} - sct.grab(region1) - bmp1 = id(MSS.bmp) - - # Grab the area 2, the cached BMP is used - # Same sizes but different positions - region2 = {"top": 200, "left": 200, "width": 200, "height": 200} - sct.grab(region2) - bmp2 = id(MSS.bmp) - assert bmp1 == bmp2 - - # Grab the area 2 again, the cached BMP is used - sct.grab(region2) - assert bmp2 == id(MSS.bmp) - - -def run_child_thread(loops): - for _ in range(loops): - with mss.mss() as sct: - sct.grab(sct.monitors[1]) - - -def test_thread_safety(): - """Thread safety test for issue #150. - The following code will throw a ScreenShotError exception if thread-safety is not guaranted. - """ - # Let thread 1 finished ahead of thread 2 - thread1 = threading.Thread(target=run_child_thread, args=(30,)) - thread2 = threading.Thread(target=run_child_thread, args=(50,)) - thread1.start() - thread2.start() - thread1.join() - thread2.join() diff --git a/mss/windows.py b/mss/windows.py deleted file mode 100644 index 478fecb3..00000000 --- a/mss/windows.py +++ /dev/null @@ -1,287 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import sys -import ctypes -import threading -from ctypes import POINTER, Structure, WINFUNCTYPE, c_void_p -from ctypes.wintypes import ( - BOOL, - DOUBLE, - DWORD, - HBITMAP, - HDC, - HGDIOBJ, - HWND, - INT, - LONG, - LPARAM, - RECT, - UINT, - WORD, -) -from typing import TYPE_CHECKING - -from .base import MSSBase -from .exception import ScreenShotError - -if TYPE_CHECKING: - from typing import Any, Dict # noqa - - from .models import Monitor, Monitors # noqa - from .screenshot import ScreenShot # noqa - -__all__ = ("MSS",) - - -CAPTUREBLT = 0x40000000 -DIB_RGB_COLORS = 0 -SRCCOPY = 0x00CC0020 - - -class BITMAPINFOHEADER(Structure): - """ Information about the dimensions and color format of a DIB. """ - - _fields_ = [ - ("biSize", DWORD), - ("biWidth", LONG), - ("biHeight", LONG), - ("biPlanes", WORD), - ("biBitCount", WORD), - ("biCompression", DWORD), - ("biSizeImage", DWORD), - ("biXPelsPerMeter", LONG), - ("biYPelsPerMeter", LONG), - ("biClrUsed", DWORD), - ("biClrImportant", DWORD), - ] - - -class BITMAPINFO(Structure): - """ - Structure that defines the dimensions and color information for a DIB. - """ - - _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", DWORD * 3)] - - -MONITORNUMPROC = WINFUNCTYPE(INT, DWORD, DWORD, POINTER(RECT), DOUBLE) - - -# C functions that will be initialised later. -# -# This is a dict: -# cfunction: (attr, argtypes, restype) -# -# Available attr: gdi32, user32. -# -# Note: keep it sorted by cfunction. -CFUNCTIONS = { - "BitBlt": ("gdi32", [HDC, INT, INT, INT, INT, HDC, INT, INT, DWORD], BOOL), - "CreateCompatibleBitmap": ("gdi32", [HDC, INT, INT], HBITMAP), - "CreateCompatibleDC": ("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), - "SelectObject": ("gdi32", [HDC, HGDIOBJ], HGDIOBJ), -} - - -class MSS(MSSBase): - """ Multiple ScreenShots implementation for Microsoft Windows. """ - - __slots__ = {"_bbox", "_bmi", "_data", "gdi32", "user32"} - - # Class attributes instanced one time to prevent resource leaks. - bmp = None - memdc = None - - # A dict to maintain *srcdc* values created by multiple threads. - _srcdc_dict = {} # type: Dict[threading.Thread, int] - - def __init__(self, **_): - # type: (Any) -> None - """ Windows initialisations. """ - - super().__init__() - - self.user32 = ctypes.WinDLL("user32") - self.gdi32 = ctypes.WinDLL("gdi32") - self._set_cfunctions() - self._set_dpi_awareness() - - self._bbox = {"height": 0, "width": 0} - self._data = ctypes.create_string_buffer(0) # type: ctypes.Array[ctypes.c_char] - - srcdc = self._get_srcdc() - if not MSS.memdc: - MSS.memdc = self.gdi32.CreateCompatibleDC(srcdc) - - bmi = BITMAPINFO() - bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) - bmi.bmiHeader.biPlanes = 1 # Always 1 - bmi.bmiHeader.biBitCount = 32 # See grab.__doc__ [2] - 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._bmi = bmi - - def _set_cfunctions(self): - """ Set all ctypes functions and attach them to attributes. """ - - cfactory = self._cfactory - attrs = { - "gdi32": self.gdi32, - "user32": self.user32, - } - for func, (attr, argtypes, restype) in CFUNCTIONS.items(): - cfactory( - attr=attrs[attr], - func=func, - argtypes=argtypes, - restype=restype, - ) # type: ignore - - def _set_dpi_awareness(self): - """ Set DPI aware to capture full screen on Hi-DPI monitors. """ - - version = sys.getwindowsversion()[:2] # pylint: disable=no-member - if version >= (6, 3): - # Windows 8.1+ - # Here 2 = PROCESS_PER_MONITOR_DPI_AWARE, which means: - # per monitor DPI aware. This app checks for the DPI when it is - # created and adjusts the scale factor whenever the DPI changes. - # These applications are not automatically scaled by the system. - ctypes.windll.shcore.SetProcessDpiAwareness(2) - elif (6, 0) <= version < (6, 3): - # Windows Vista, 7, 8 and Server 2012 - self.user32.SetProcessDPIAware() - - def _get_srcdc(self): - """ - Retrieve a thread-safe HDC from GetWindowDC(). - In multithreading, if the thread who creates *srcdc* is dead, *srcdc* will - no longer be valid to grab the screen. The *srcdc* attribute is replaced - with *_srcdc_dict* to maintain the *srcdc* values in multithreading. - Since the current thread and main thread are always alive, reuse their *srcdc* value first. - """ - cur_thread, main_thread = threading.current_thread(), threading.main_thread() - srcdc = MSS._srcdc_dict.get(cur_thread) or MSS._srcdc_dict.get(main_thread) - if not srcdc: - srcdc = MSS._srcdc_dict[cur_thread] = self.user32.GetWindowDC(0) - return srcdc - - def _monitors_impl(self): - # type: () -> None - """ Get positions of monitors. It will populate self._monitors. """ - - int_ = int - user32 = self.user32 - get_system_metrics = user32.GetSystemMetrics - - # All monitors - self._monitors.append( - { - "left": int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN - "top": int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN - "width": int_(get_system_metrics(78)), # SM_CXVIRTUALSCREEN - "height": int_(get_system_metrics(79)), # SM_CYVIRTUALSCREEN - } - ) - - # Each monitors - def _callback(monitor, data, rect, dc_): - # types: (int, HDC, LPRECT, LPARAM) -> int - """ - Callback for monitorenumproc() function, it will return - a RECT with appropriate values. - """ - # pylint: disable=unused-argument - - rct = rect.contents - self._monitors.append( - { - "left": int_(rct.left), - "top": int_(rct.top), - "width": int_(rct.right - rct.left), - "height": int_(rct.bottom - rct.top), - } - ) - return 1 - - callback = MONITORNUMPROC(_callback) - user32.EnumDisplayMonitors(0, 0, callback, 0) - - def _grab_impl(self, monitor): - # type: (Monitor) -> ScreenShot - """ - Retrieve all pixels from a monitor. Pixels have to be RGB. - - In the code, there are 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 - """ - - srcdc, memdc = self._get_srcdc(), MSS.memdc - width, height = monitor["width"], monitor["height"] - - if (self._bbox["height"], self._bbox["width"]) != (height, width): - self._bbox = monitor - self._bmi.bmiHeader.biWidth = width - self._bmi.bmiHeader.biHeight = -height # Why minus? [1] - self._data = ctypes.create_string_buffer(width * height * 4) # [2] - if MSS.bmp: - self.gdi32.DeleteObject(MSS.bmp) - MSS.bmp = self.gdi32.CreateCompatibleBitmap(srcdc, width, height) - self.gdi32.SelectObject(memdc, MSS.bmp) - - self.gdi32.BitBlt( - memdc, - 0, - 0, - width, - height, - srcdc, - monitor["left"], - monitor["top"], - SRCCOPY | CAPTUREBLT, - ) - bits = self.gdi32.GetDIBits( - memdc, MSS.bmp, 0, height, self._data, self._bmi, DIB_RGB_COLORS - ) - if bits != height: - raise ScreenShotError("gdi32.GetDIBits() failed.") - - return self.cls_image(bytearray(self._data), monitor) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..66a41a55 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,194 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mss" +description = "An ultra fast cross-platform multiple screenshots module in pure python using ctypes." +readme = "README.md" +requires-python = ">= 3.9" +authors = [ + { name = "Mickaël Schoentgen", email="contact@tiger-222.fr" }, +] +maintainers = [ + { name = "Mickaël Schoentgen", email="contact@tiger-222.fr" }, +] +license = { file = "LICENSE.txt" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: MacOS X", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: Unix", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "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", +] +keywords = [ + "BitBlt", + "ctypes", + "EnumDisplayMonitors", + "CGGetActiveDisplayList", + "CGImageGetBitsPerPixel", + "monitor", + "screen", + "screenshot", + "screencapture", + "screengrab", + "XGetImage", + "XGetWindowAttributes", + "XRRGetScreenResourcesCurrent", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/BoboTiG/python-mss" +Documentation = "https://python-mss.readthedocs.io" +Changelog = "https://github.com/BoboTiG/python-mss/blob/main/CHANGELOG.md" +Source = "https://github.com/BoboTiG/python-mss" +Sponsor = "https://github.com/sponsors/BoboTiG" +Tracker = "https://github.com/BoboTiG/python-mss/issues" +"Released Versions" = "https://github.com/BoboTiG/python-mss/releases" + +[project.scripts] +mss = "mss.__main__:main" + +[project.optional-dependencies] +dev = [ + "build==1.3.0", + "lxml==6.0.2", + "mypy==1.18.2", + "ruff==0.14.6", + "twine==6.2.0", +] +docs = [ + "shibuya==2025.10.21", + "sphinx==8.2.3", + "sphinx-copybutton==0.5.2", + "sphinx-new-tab-link==0.8.0", +] +tests = [ + "numpy==2.2.4 ; sys_platform == 'linux' and python_version == '3.13'", + "pillow==11.3.0 ; sys_platform == 'linux' and python_version == '3.13'", + "pytest==8.4.2", + "pytest-cov==7.0.0", + "pytest-rerunfailures==16.0.1", + "pyvirtualdisplay==3.0 ; sys_platform == 'linux'", +] + +[tool.hatch.version] +path = "src/mss/__init__.py" + +[tool.hatch.build] +skip-excluded-dirs = true + +[tool.hatch.build.targets.sdist] +only-include = [ + "CHANGELOG.md", + "CHANGES.md", + "CONTRIBUTORS.md", + "docs/source", + "src", +] + +[tool.hatch.build.targets.wheel] +packages = [ + "src/mss", +] + +[tool.mypy] +# Ensure we know what we do +warn_redundant_casts = true +warn_unused_ignores = true +warn_unused_configs = true + +# Imports management +ignore_missing_imports = true +follow_imports = "skip" + +# Ensure full coverage +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_calls = true + +# Restrict dynamic typing (a little) +# e.g. `x: List[Any]` or x: List` +# disallow_any_generics = true + +strict_equality = true + +[tool.pytest.ini_options] +pythonpath = "src" +markers = ["without_libraries"] +addopts = """ + --showlocals + --strict-markers + -r fE + -v + --cov=src/mss + --cov-report=term-missing:skip-covered +""" + +[tool.ruff] +exclude = [ + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "venv", +] +line-length = 120 +indent-width = 4 +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 + "ISC001", # conflict + "T201", # `print()` +] + +[tool.ruff.lint.per-file-ignores] +"docs/source/*" = [ + "ERA001", # commented code + "INP001", # file `xxx` is part of an implicit namespace package +] +"src/tests/*" = [ + "FBT001", # boolean-typed positional argument in function definition + "PLR2004", # magic value used in comparison + "S101", # use of `assert` detected + "S602", # `subprocess` call with `shell=True` + "S603", # `subprocess` call: 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" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index cfdb620f..00000000 --- a/setup.cfg +++ /dev/null @@ -1,60 +0,0 @@ -[metadata] -name = mss -version = 6.1.0 -author = Mickaël 'Tiger-222' Schoentgen -author-email = contact@tiger-222.fr -description = An ultra fast cross-platform multiple screenshots module in pure python using ctypes. -long_description = file: README.rst -url = https://github.com/BoboTiG/python-mss -home-page = https://pypi.org/project/mss/ -project_urls = - Documentation = https://python-mss.readthedocs.io - Source = https://github.com/BoboTiG/python-mss - Tracker = https://github.com/BoboTiG/python-mss/issues -keywords = screen, screenshot, screencapture, screengrab -license = MIT -license_files = - LICENSE -platforms = Darwin, Linux, Windows -classifiers = - Development Status :: 5 - Production/Stable - License :: OSI Approved :: MIT License - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Topic :: Multimedia :: Graphics :: Capture :: Screen Capture - Topic :: Software Development :: Libraries - -[options] -zip-safe = False -include_package_data = True -packages = mss -python_requires = >=3.5 - -[options.entry_points] -console_scripts = - mss = mss.__main__:main - -[flake8] -ignore = - # E203 whitespace before ':', but E203 is not PEP 8 compliant - E203 - # W503 line break before binary operator, but W503 is not PEP 8 compliant - W503 -max-line-length = 120 - -[tool:pytest] -addopts = - --showlocals - --strict - --failed-first - -r fE - -v - # Trait all tests as flaky by default - --force-flaky - --no-success-flaky-report diff --git a/setup.py b/setup.py deleted file mode 100644 index 056ba45d..00000000 --- a/setup.py +++ /dev/null @@ -1,4 +0,0 @@ -import setuptools - - -setuptools.setup() diff --git a/src/mss/__init__.py b/src/mss/__init__.py new file mode 100644 index 00000000..c8c9a1e3 --- /dev/null +++ b/src/mss/__init__.py @@ -0,0 +1,27 @@ +"""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.exception import ScreenShotError +from mss.factory import mss + +__version__ = "10.1.1.dev0" +__author__ = "Mickaël Schoentgen" +__date__ = "2013-2025" +__copyright__ = f""" +Copyright (c) {__date__}, {__author__} + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee or royalty is hereby +granted, provided that the above copyright notice appear in all copies +and that both that copyright notice and this permission notice appear +in supporting documentation or portions thereof, including +modifications, that you make. +""" +__all__ = ("ScreenShotError", "mss") diff --git a/mss/__main__.py b/src/mss/__main__.py similarity index 68% rename from mss/__main__.py rename to src/mss/__main__.py index 939e7ae0..384ad344 100644 --- a/mss/__main__.py +++ b/src/mss/__main__.py @@ -1,27 +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. """ import os.path import sys from argparse import ArgumentParser -from typing import TYPE_CHECKING - -from . import __version__ -from .exception import ScreenShotError -from .factory import mss -from .tools import to_png -if TYPE_CHECKING: - from typing import List, Optional # noqa +from mss import __version__ +from mss.exception import ScreenShotError +from mss.factory import mss +from mss.tools import to_png -def main(args=None): - # type: (Optional[List[str]]) -> int - """ Main logic. """ - - cli_args = ArgumentParser() +def main(*args: str) -> int: + """Main logic.""" + cli_args = ArgumentParser(prog="mss") cli_args.add_argument( "-c", "--coordinates", @@ -37,12 +30,9 @@ def main(args=None): 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( - "-o", "--output", default="monitor-{mon}.png", help="the output file name" - ) + 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( "-q", "--quiet", @@ -52,7 +42,7 @@ def main(args=None): ) cli_args.add_argument("-v", "--version", action="version", version=__version__) - options = cli_args.parse_args(args) + options = cli_args.parse_args(args or None) kwargs = {"mon": options.monitor, "output": options.output} if options.coordinates: try: @@ -71,7 +61,7 @@ def main(args=None): kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png" try: - with mss() as sct: + with mss(with_cursor=options.with_cursor) as sct: if options.coordinates: output = kwargs["output"].format(**kwargs["mon"]) sct_img = sct.grab(kwargs["mon"]) @@ -84,8 +74,10 @@ def main(args=None): print(os.path.realpath(file_name)) return 0 except ScreenShotError: - return 1 + if options.quiet: + return 1 + raise -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) +if __name__ == "__main__": # pragma: nocover + sys.exit(main()) diff --git a/src/mss/base.py b/src/mss/base.py new file mode 100644 index 00000000..0a6c443a --- /dev/null +++ b/src/mss/base.py @@ -0,0 +1,267 @@ +"""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 +from datetime import datetime +from threading import Lock +from typing import TYPE_CHECKING, Any + +from mss.exception import ScreenShotError +from mss.screenshot import ScreenShot +from mss.tools import to_png + +if TYPE_CHECKING: # pragma: nocover + from collections.abc import Callable, Iterator + + from mss.models import Monitor, Monitors + +try: + from datetime import UTC +except ImportError: # pragma: nocover + # Python < 3.11 + from datetime import timezone + + UTC = timezone.utc + +lock = Lock() + +OPAQUE = 255 + + +class MSSBase(metaclass=ABCMeta): + """This class will be overloaded by a system specific one.""" + + __slots__ = {"_monitors", "cls_image", "compression_level", "with_cursor"} + + def __init__( + self, + /, + *, + backend: str = "default", + compression_level: int = 6, + with_cursor: bool = False, + # Linux only + display: bytes | str | None = None, # noqa: ARG002 + # Mac only + max_displays: int = 32, # noqa: ARG002 + ) -> None: + self.cls_image: type[ScreenShot] = ScreenShot + self.compression_level = compression_level + self.with_cursor = with_cursor + self._monitors: Monitors = [] + # If there isn't a factory that removed the "backend" argument, make sure that it was set to "default". + # Factories that do backend-specific dispatch should remove that argument. + if backend != "default": + msg = 'The only valid backend on this platform is "default".' + raise ScreenShotError(msg) + + def __enter__(self) -> MSSBase: # noqa:PYI034 + """For the cool call `with MSS() as mss:`.""" + return self + + 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.""" + + @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. + """ + + @abstractmethod + def _monitors_impl(self) -> None: + """Get positions of monitors (has to be run using a threading lock). + It must populate self._monitors. + """ + + def close(self) -> None: # noqa:B027 + """Clean-up.""" + + 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. + + :param monitor: The coordinates and size of the box to capture. + See :meth:`monitors ` for object details. + :return :class:`ScreenShot `. + """ + # Convert PIL bbox style + if isinstance(monitor, tuple): + monitor = { + "left": monitor[0], + "top": monitor[1], + "width": monitor[2] - monitor[0], + "height": monitor[3] - monitor[1], + } + + with lock: + screenshot = self._grab_impl(monitor) + if self.with_cursor and (cursor := self._cursor_impl()): + return self._merge(screenshot, cursor) + return screenshot + + @property + def monitors(self) -> Monitors: + """Get 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 + } + """ + if not self._monitors: + with lock: + self._monitors_impl() + + return self._monitors + + def save( + self, + /, + *, + mon: int = 0, + output: str = "monitor-{mon}.png", + callback: Callable[[str], None] | None = None, + ) -> Iterator[str]: + """Grab a screenshot and save it to a file. + + :param int mon: The monitor to screenshot (default=0). + -1: grab one screenshot of all monitors + 0: grab one screenshot by monitor + N: grab the screenshot 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 screenshot y-coordinate of the upper-left corner + - `{left}`: the screenshot x-coordinate of the upper-left corner + - `{width}`: the screenshot's width + - `{height}`: the screenshot'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 + screenshot to a file. Take the `output` argument as parameter. + + :return generator: Created file(s). + """ + monitors = self.monitors + if not monitors: + msg = "No monitor found." + raise ScreenShotError(msg) + + if mon == 0: + # 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): + callback(fname) + sct = self.grab(monitor) + to_png(sct.rgb, sct.size, level=self.compression_level, output=fname) + yield fname + else: + # A screenshot of all monitors together or + # a screenshot of the monitor N. + mon = 0 if mon == -1 else mon + try: + monitor = monitors[mon] + except IndexError as exc: + msg = f"Monitor {mon!r} does not exist." + raise ScreenShotError(msg) from exc + + output = output.format(mon=mon, date=datetime.now(UTC) if "{date" in output else None, **monitor) + if callable(callback): + callback(output) + sct = self.grab(monitor) + to_png(sct.rgb, sct.size, level=self.compression_level, output=output) + yield output + + def shot(self, /, **kwargs: Any) -> str: + """Helper to save the screenshot of the 1st monitor, by default. + You can pass the same arguments as for ``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.""" + (cx, cy), (cw, ch) = cursor.pos, cursor.size + (x, y), (w, h) = screenshot.pos, screenshot.size + + cx2, cy2 = cx + cw, cy + ch + x2, y2 = x + w, y + h + + overlap = cx < x2 and cx2 > x and cy < y2 and cy2 > y + if not overlap: + return screenshot + + screen_raw = screenshot.raw + cursor_raw = cursor.raw + + cy, cy2 = (cy - y) * 4, (cy2 - y2) * 4 + cx, cx2 = (cx - x) * 4, (cx2 - x2) * 4 + start_count_y = -cy if cy < 0 else 0 + start_count_x = -cx if cx < 0 else 0 + stop_count_y = ch * 4 - max(cy2, 0) + stop_count_x = cw * 4 - max(cx2, 0) + rgb = range(3) + + for count_y in range(start_count_y, stop_count_y, 4): + pos_s = (count_y + cy) * w + cx + pos_c = count_y * cw + + for count_x in range(start_count_x, stop_count_x, 4): + spos = pos_s + count_x + cpos = pos_c + count_x + alpha = cursor_raw[cpos + 3] + + if not alpha: + continue + + if alpha == OPAQUE: + screen_raw[spos : spos + 3] = cursor_raw[cpos : cpos + 3] + else: + alpha2 = alpha / 255 + for i in rgb: + screen_raw[spos + i] = int(cursor_raw[cpos + i] * alpha2 + screen_raw[spos + i] * (1 - alpha2)) + + return screenshot + + @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 diff --git a/mss/darwin.py b/src/mss/darwin.py similarity index 55% rename from mss/darwin.py rename to src/mss/darwin.py index 92516365..f001398b 100644 --- a/mss/darwin.py +++ b/src/mss/darwin.py @@ -1,83 +1,73 @@ +"""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 ctypes import ctypes.util import sys -from ctypes import ( - POINTER, - Structure, - c_double, - c_float, - c_int32, - c_uint64, - c_ubyte, - c_uint32, - c_void_p, -) +from ctypes import POINTER, Structure, c_double, c_float, c_int32, c_ubyte, c_uint32, c_uint64, c_void_p from platform import mac_ver -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from .base import MSSBase -from .exception import ScreenShotError -from .screenshot import Size +from mss.base import MSSBase +from mss.exception import ScreenShotError +from mss.screenshot import ScreenShot, Size -if TYPE_CHECKING: - from typing import Any, List, Type, Union # noqa - - from .models import Monitor, Monitors # noqa - from .screenshot import ScreenShot # noqa +if TYPE_CHECKING: # pragma: nocover + from mss.models import CFunctions, Monitor __all__ = ("MSS",) +MAC_VERSION_CATALINA = 10.16 + +kCGWindowImageBoundsIgnoreFraming = 1 << 0 # noqa: N816 +kCGWindowImageNominalResolution = 1 << 4 # noqa: N816 +kCGWindowImageShouldBeOpaque = 1 << 1 # noqa: N816 +# Note: set `IMAGE_OPTIONS = 0` to turn on scaling (see issue #257 for more information) +IMAGE_OPTIONS = kCGWindowImageBoundsIgnoreFraming | kCGWindowImageShouldBeOpaque | kCGWindowImageNominalResolution -def cgfloat(): - # type: () -> Union[Type[c_double], Type[c_float]] - """ Get the appropriate value for a float. """ - return c_double if sys.maxsize > 2 ** 32 else c_float +def cgfloat() -> type[c_double | c_float]: + """Get the appropriate value for a float.""" + return c_double if sys.maxsize > 2**32 else c_float class CGPoint(Structure): - """ Structure that contains coordinates of a rectangle. """ + """Structure that contains coordinates of a rectangle.""" - _fields_ = [("x", cgfloat()), ("y", cgfloat())] + _fields_ = (("x", cgfloat()), ("y", cgfloat())) - def __repr__(self): - return "{}(left={} top={})".format(type(self).__name__, self.x, self.y) + def __repr__(self) -> str: + return f"{type(self).__name__}(left={self.x} top={self.y})" class CGSize(Structure): - """ Structure that contains dimensions of an rectangle. """ + """Structure that contains dimensions of an rectangle.""" - _fields_ = [("width", cgfloat()), ("height", cgfloat())] + _fields_ = (("width", cgfloat()), ("height", cgfloat())) - def __repr__(self): - return "{}(width={} height={})".format( - type(self).__name__, self.width, self.height - ) + def __repr__(self) -> str: + return f"{type(self).__name__}(width={self.width} height={self.height})" class CGRect(Structure): - """ Structure that contains information about a rectangle. """ + """Structure that contains information about a rectangle.""" - _fields_ = [("origin", CGPoint), ("size", CGSize)] + _fields_ = (("origin", CGPoint), ("size", CGSize)) - def __repr__(self): - return "{}<{} {}>".format(type(self).__name__, self.origin, self.size) + def __repr__(self) -> str: + return f"{type(self).__name__}<{self.origin} {self.size}>" # C functions that will be initialised later. # -# This is a dict: -# cfunction: (attr, argtypes, restype) -# # Available attr: core. # # Note: keep it sorted by cfunction. -CFUNCTIONS = { +CFUNCTIONS: CFunctions = { + # Syntax: cfunction: (attr, argtypes, restype) "CGDataProviderCopyData": ("core", [c_void_p], c_void_p), "CGDisplayBounds": ("core", [c_uint32], CGRect), "CGDisplayRotation": ("core", [c_uint32], c_float), @@ -85,11 +75,7 @@ def __repr__(self): "CFDataGetLength": ("core", [c_void_p], c_uint64), "CFRelease": ("core", [c_void_p], c_void_p), "CGDataProviderRelease": ("core", [c_void_p], c_void_p), - "CGGetActiveDisplayList": ( - "core", - [c_uint32, POINTER(c_uint32), POINTER(c_uint32)], - c_int32, - ), + "CGGetActiveDisplayList": ("core", [c_uint32, POINTER(c_uint32), POINTER(c_uint32)], c_int32), "CGImageGetBitsPerPixel": ("core", [c_void_p], int), "CGImageGetBytesPerRow": ("core", [c_void_p], int), "CGImageGetDataProvider": ("core", [c_void_p], c_void_p), @@ -97,64 +83,49 @@ def __repr__(self): "CGImageGetWidth": ("core", [c_void_p], int), "CGRectStandardize": ("core", [CGRect], CGRect), "CGRectUnion": ("core", [CGRect, CGRect], CGRect), - "CGWindowListCreateImage": ( - "core", - [CGRect, c_uint32, c_uint32, c_uint32], - c_void_p, - ), + "CGWindowListCreateImage": ("core", [CGRect, c_uint32, c_uint32, c_uint32], c_void_p), } class MSS(MSSBase): - """ - Multiple ScreenShots implementation for macOS. + """Multiple ScreenShots implementation for macOS. It uses intensively the CoreGraphics library. """ __slots__ = {"core", "max_displays"} - def __init__(self, **_): - """ macOS initialisations. """ + def __init__(self, /, **kwargs: Any) -> None: + """MacOS initialisations.""" + super().__init__(**kwargs) - super().__init__() - - self.max_displays = 32 + self.max_displays = kwargs.get("max_displays", 32) self._init_library() self._set_cfunctions() - def _init_library(self): - """ Load the CoreGraphics library. """ + 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 - # pylint: disable=line-too-long coregraphics = "/System/Library/Frameworks/CoreGraphics.framework/Versions/Current/CoreGraphics" if not coregraphics: - raise ScreenShotError("No CoreGraphics library found.") + msg = "No CoreGraphics library found." + raise ScreenShotError(msg) self.core = ctypes.cdll.LoadLibrary(coregraphics) - def _set_cfunctions(self): - # type: () -> None - """ Set all ctypes functions and attach them to attributes. """ - + def _set_cfunctions(self) -> None: + """Set all ctypes functions and attach them to attributes.""" cfactory = self._cfactory attrs = {"core": self.core} for func, (attr, argtypes, restype) in CFUNCTIONS.items(): - cfactory( - attr=attrs[attr], - func=func, - argtypes=argtypes, # type: ignore - restype=restype, - ) - - def _monitors_impl(self): - # type: () -> None - """ Get positions of monitors. It will populate self._monitors. """ + cfactory(attrs[attr], func, argtypes, restype) + def _monitors_impl(self) -> None: + """Get positions of monitors. It will populate self._monitors.""" int_ = int core = self.core @@ -164,28 +135,29 @@ def _monitors_impl(self): all_monitors = CGRect() self._monitors.append({}) - # Each monitors + # Each monitor display_count = c_uint32(0) active_displays = (c_uint32 * self.max_displays)() - core.CGGetActiveDisplayList( - self.max_displays, active_displays, ctypes.byref(display_count) - ) - rotations = {0.0: "normal", 90.0: "right", -90.0: "left"} + core.CGGetActiveDisplayList(self.max_displays, active_displays, ctypes.byref(display_count)) for idx in range(display_count.value): display = active_displays[idx] rect = core.CGDisplayBounds(display) rect = core.CGRectStandardize(rect) width, height = rect.size.width, rect.size.height - rot = core.CGDisplayRotation(display) - if rotations[rot] in ["left", "right"]: + + # 0.0: normal + # 90.0: right + # -90.0: left + if core.CGDisplayRotation(display) in {90.0, -90.0}: width, height = height, width + self._monitors.append( { "left": int_(rect.origin.x), "top": int_(rect.origin.y), "width": int_(width), "height": int_(height), - } + }, ) # Update AiO monitor's values @@ -199,20 +171,15 @@ def _monitors_impl(self): "height": int_(all_monitors.size.height), } - def _grab_impl(self, monitor): - # type: (Monitor) -> ScreenShot - """ Retrieve all pixels from a monitor. Pixels have to be RGB. """ - - # pylint: disable=too-many-locals - + def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: + """Retrieve all pixels from a monitor. Pixels have to be RGB.""" core = self.core - rect = CGRect( - (monitor["left"], monitor["top"]), (monitor["width"], monitor["height"]) - ) + 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: - raise ScreenShotError("CoreGraphics.CGWindowListCreateImage() failed.") + msg = "CoreGraphics.CGWindowListCreateImage() failed." + raise ScreenShotError(msg) width = core.CGImageGetWidth(image_ref) height = core.CGImageGetHeight(image_ref) @@ -244,3 +211,7 @@ def _grab_impl(self, monitor): core.CFRelease(copy_data) return self.cls_image(data, monitor, size=Size(width, height)) + + def _cursor_impl(self) -> ScreenShot | None: + """Retrieve all cursor data. Pixels have to be RGB.""" + return None diff --git a/src/mss/exception.py b/src/mss/exception.py new file mode 100644 index 00000000..7fdf2113 --- /dev/null +++ b/src/mss/exception.py @@ -0,0 +1,15 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +from typing import Any + + +class ScreenShotError(Exception): + """Error handling class.""" + + def __init__(self, message: str, /, *, details: dict[str, Any] | None = None) -> None: + super().__init__(message) + self.details = details or {} diff --git a/src/mss/factory.py b/src/mss/factory.py new file mode 100644 index 00000000..29cb3ed1 --- /dev/null +++ b/src/mss/factory.py @@ -0,0 +1,40 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import platform +from typing import Any + +from mss.base import MSSBase +from mss.exception import ScreenShotError + + +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. + + It then proxies its arguments to the class for + instantiation. + """ + os_ = platform.system().lower() + + if os_ == "darwin": + from mss import darwin # noqa: PLC0415 + + return darwin.MSS(**kwargs) + + if os_ == "linux": + from mss import linux # noqa: PLC0415 + + return linux.MSS(**kwargs) + + if os_ == "windows": + from mss import windows # noqa: PLC0415 + + return windows.MSS(**kwargs) + + msg = f"System {os_!r} not (yet?) implemented." + raise ScreenShotError(msg) diff --git a/src/mss/linux/__init__.py b/src/mss/linux/__init__.py new file mode 100644 index 00000000..46993f1f --- /dev/null +++ b/src/mss/linux/__init__.py @@ -0,0 +1,23 @@ +from typing import Any + +from mss.base import MSSBase +from mss.exception import ScreenShotError + + +def mss(backend: str = "default", **kwargs: Any) -> MSSBase: + backend = backend.lower() + if backend in {"default", "xlib"}: + from . import xlib # noqa: PLC0415 + + return xlib.MSS(**kwargs) + if backend == "xgetimage": + from . import xgetimage # noqa: PLC0415 + + return xgetimage.MSS(**kwargs) + msg = f"Backend {backend!r} not (yet?) implemented." + raise ScreenShotError(msg) + + +# Alias in upper-case for backward compatibility. This is a supported name in the docs. +def MSS(*args, **kwargs) -> MSSBase: # type: ignore[no-untyped-def] # noqa: N802, ANN002, ANN003 + return mss(*args, **kwargs) diff --git a/src/mss/linux/xcb.py b/src/mss/linux/xcb.py new file mode 100644 index 00000000..966def06 --- /dev/null +++ b/src/mss/linux/xcb.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from ctypes import Structure, c_int, c_uint8, c_uint16, c_uint32 + +from . import xcbgen + +# We import these just so they're re-exported to our users. +# ruff: noqa: F401 +from .xcbgen import ( + RANDR_MAJOR_VERSION, + RANDR_MINOR_VERSION, + RENDER_MAJOR_VERSION, + RENDER_MINOR_VERSION, + XFIXES_MAJOR_VERSION, + XFIXES_MINOR_VERSION, + Atom, + BackingStore, + Colormap, + Depth, + DepthIterator, + Drawable, + Format, + GetGeometryReply, + GetImageReply, + GetPropertyReply, + ImageFormat, + ImageOrder, + Keycode, + Pixmap, + RandrCrtc, + RandrGetCrtcInfoReply, + RandrGetScreenResourcesCurrentReply, + RandrGetScreenResourcesReply, + RandrMode, + RandrModeInfo, + RandrOutput, + RandrQueryVersionReply, + RandrSetConfig, + RenderDirectformat, + RenderPictdepth, + RenderPictdepthIterator, + RenderPictformat, + RenderPictforminfo, + RenderPictscreen, + RenderPictscreenIterator, + RenderPictType, + RenderPictvisual, + RenderQueryPictFormatsReply, + RenderQueryVersionReply, + RenderSubPixel, + Screen, + ScreenIterator, + Setup, + SetupIterator, + 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_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_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, + xfixes_get_cursor_image, + xfixes_get_cursor_image_cursor_image, + xfixes_query_version, +) + +# These are also here to re-export. +from .xcbhelpers import LIB, Connection, 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", +} + + +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) + if conn_errmsg: + msg += conn_errmsg + else: + msg += f"error code {conn_err}" + raise XError(msg) + + return conn_p.contents, pref_screen_num.value + + +def disconnect(conn: Connection) -> None: + conn_err = LIB.xcb.xcb_connection_has_error(conn) + # XCB won't free its connection structures until we disconnect, even in the event of an error. + LIB.xcb.xcb_disconnect(conn) + if conn_err != 0: + msg = "Connection to X server closed: " + conn_errmsg = XCB_CONN_ERRMSG.get(conn_err) + if conn_errmsg: + msg += conn_errmsg + else: + msg += 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..65d808a8 --- /dev/null +++ b/src/mss/linux/xcbgen.py @@ -0,0 +1,805 @@ +# 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, + 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 +XFIXES_MAJOR_VERSION = 6 +XFIXES_MINOR_VERSION = 0 + +# Enum classes + + +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): + pass + + +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): + pass + + +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): + pass + + +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 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 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 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 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(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 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 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.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.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 + 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.argtypes = (Connection,) + LIB.xcb.xcb_no_operation.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.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.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..786799cb --- /dev/null +++ b/src/mss/linux/xcbhelpers.py @@ -0,0 +1,581 @@ +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_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): + pass + + +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), + ) + + +#### 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}" + f"{ext_desc}" + f"\n Minor opcode of failed request: {minor_desc}" + 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 .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_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 + + 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_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..9f127948 --- /dev/null +++ b/src/mss/linux/xgetimage.py @@ -0,0 +1,256 @@ +from typing import Any + +from mss.base import MSSBase +from mss.exception import ScreenShotError +from mss.models import Monitor +from mss.screenshot import ScreenShot + +from . import xcb +from .xcb import LIB + +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 MSS(MSSBase): + """Multiple ScreenShots implementation for GNU/Linux. + + This implementation is based on XCB, using the GetImage request. + It can optionally use some extensions: + * RandR: Enumerate individual monitors' sizes. + * XFixes: Including the cursor. + """ + + def __init__(self, /, **kwargs: Any) -> None: # noqa: PLR0912 + super().__init__(**kwargs) + + display = kwargs.get("display", b"") + if not display: + display = None + + self.conn: xcb.Connection | None + self.conn, pref_screen_num = xcb.connect(display) + + # Let XCB pre-populate its internal cache regarding the extensions we might use, while we finish setup. + LIB.xcb.xcb_prefetch_extension_data(self.conn, LIB.randr_id) + LIB.xcb.xcb_prefetch_extension_data(self.conn, LIB.xfixes_id) + + # Get the connection setup information that was included when we connected. + xcb_setup = LIB.xcb.xcb_get_setup(self.conn).contents + screens = xcb.setup_roots(xcb_setup) + pref_screen = screens[pref_screen_num] + self.root = self.drawable = pref_screen.root + self.drawable = self.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 = pref_screen.root_depth + self.drawable_visual_id = pref_screen.root_visual.value + # 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(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.value == 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: + if self.conn is not None: + xcb.disconnect(self.conn) + self.conn = None + + def _monitors_impl(self) -> None: + """Get positions of monitors. It will populate self._monitors.""" + + if self.conn is None: + msg = "Cannot identify monitors while the connection is closed" + raise ScreenShotError(msg) + + # The first entry is the whole X11 screen that the root is on. That's the one that covers all the monitors. + root_geom = xcb.get_geometry(self.conn, self.root) + self._monitors.append( + { + "left": root_geom.x, + "top": root_geom.y, + "width": root_geom.width, + "height": root_geom.height, + } + ) + + # After that, we have one for each monitor on that X11 screen. For decades, that's been handled by Xrandr. + # We don't presently try to work with Xinerama. So, we're going to check the different outputs, according to + # Xrandr. If that fails, we'll just leave the one root covering everything. + + # Make sure we have the Xrandr extension we need. This will query the cache that we started populating in + # __init__. + randr_ext_data = LIB.xcb.xcb_get_extension_data(self.conn, LIB.randr_id).contents + if not randr_ext_data.present: + return + + # We ask the server to give us anything up to the version we support (i.e., what we expect the reply structs + # to look like). If the server only supports 1.2, then that's what it'll give us, and we're ok with that, but + # we also use a faster path if the server implements at least 1.3. + randr_version_data = xcb.randr_query_version(self.conn, xcb.RANDR_MAJOR_VERSION, xcb.RANDR_MINOR_VERSION) + randr_version = (randr_version_data.major_version, randr_version_data.minor_version) + if randr_version < (1, 2): + return + + screen_resources: xcb.RandrGetScreenResourcesReply | xcb.RandrGetScreenResourcesCurrentReply + # Check to see if we have the xcb_randr_get_screen_resources_current function in libxcb-randr, and that the + # server supports it. + 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.value) + crtcs = xcb.randr_get_screen_resources_current_crtcs(screen_resources) + else: + # Either the client or the server doesn't support the _current form. That's ok; we'll use the old + # function, which forces a new query to the physical monitors. + screen_resources = xcb.randr_get_screen_resources(self.conn, self.drawable) + crtcs = xcb.randr_get_screen_resources_crtcs(screen_resources) + + for crtc in crtcs: + crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, screen_resources.config_timestamp) + if crtc_info.num_outputs == 0: + continue + self._monitors.append( + {"left": crtc_info.x, "top": crtc_info.y, "width": crtc_info.width, "height": crtc_info.height} + ) + + # Extra credit would be to enumerate the virtual desktops; see + # https://specifications.freedesktop.org/wm/latest/ar01s03.html. But I don't know how widely-used that style + # is. + + def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: + """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" + + 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.value != 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)}, " + f"got {img_reply.depth},{hex(img_reply.visual.value)}" + ) + raise ScreenShotError(msg) + + return self.cls_image(img_data, monitor) + + def _cursor_impl_check_xfixes(self) -> bool: + if self.conn is None: + msg = "Cannot take screenshot while the connection is closed" + raise ScreenShotError(msg) + + xfixes_ext_data = LIB.xcb.xcb_get_extension_data(self.conn, LIB.xfixes_id).contents + 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_impl(self) -> ScreenShot: + """Retrieve all cursor data. Pixels have to be RGBx.""" + + 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_impl_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 self.cls_image(data, region) diff --git a/src/mss/linux/xlib.py b/src/mss/linux/xlib.py new file mode 100644 index 00000000..6b8208f0 --- /dev/null +++ b/src/mss/linux/xlib.py @@ -0,0 +1,600 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-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 current_thread, local +from typing import TYPE_CHECKING, Any + +from mss.base import MSSBase, lock +from mss.exception import ScreenShotError + +if TYPE_CHECKING: # pragma: nocover + from mss.models import CFunctions, Monitor + from mss.screenshot import ScreenShot + +__all__ = ("MSS",) + + +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 MSS(MSSBase): + """Multiple ScreenShots implementation for GNU/Linux. + It uses intensively the Xlib and its Xrandr extension. + """ + + __slots__ = {"_handles", "xfixes", "xlib", "xrandr"} + + 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.drawable = self._handles.root = self.xlib.XDefaultRootWindow(self._handles.display) + + 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 + 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_impl(self) -> None: + """Get positions of monitors. It will populate self._monitors.""" + display = self._handles.display + int_ = int + xrandr = self.xrandr + + 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)) + 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. + # 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 + + 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 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 + 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/models.py b/src/mss/models.py new file mode 100644 index 00000000..665a41bc --- /dev/null +++ b/src/mss/models.py @@ -0,0 +1,23 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from typing import Any, NamedTuple + +Monitor = dict[str, int] +Monitors = list[Monitor] + +Pixel = tuple[int, int, int] +Pixels = list[tuple[Pixel, ...]] + +CFunctions = dict[str, tuple[str, list[Any], Any]] + + +class Pos(NamedTuple): + left: int + top: int + + +class Size(NamedTuple): + width: int + height: int diff --git a/src/mss/py.typed b/src/mss/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/mss/screenshot.py b/src/mss/screenshot.py new file mode 100644 index 00000000..5bcf654b --- /dev/null +++ b/src/mss/screenshot.py @@ -0,0 +1,125 @@ +"""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 + +from mss.exception import ScreenShotError +from mss.models import Monitor, Pixel, Pixels, Pos, Size + +if TYPE_CHECKING: # pragma: nocover + from collections.abc import Iterator + + +class ScreenShot: + """Screenshot object. + + .. note:: + + A better name would have been *Image*, but to prevent collisions + with PIL.Image, it has been decided to use *ScreenShot*. + """ + + __slots__ = {"__pixels", "__rgb", "pos", "raw", "size"} + + def __init__(self, data: bytearray, monitor: Monitor, /, *, size: Size | None = None) -> None: + self.__pixels: Pixels | None = None + self.__rgb: bytes | None = None + + #: Bytearray of the raw BGRA pixels retrieved by ctypes + #: OS independent implementations. + self.raw = data + + #: NamedTuple of the screenshot coordinates. + self.pos = Pos(monitor["left"], monitor["top"]) + + #: NamedTuple of the screenshot size. + self.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. + + See https://docs.scipy.org/doc/numpy/reference/arrays.interface.html + """ + return { + "version": 3, + "shape": (self.height, self.width, 4), + "typestr": "|u1", + "data": self.raw, + } + + @classmethod + def from_size(cls: type[ScreenShot], data: bytearray, width: int, height: int, /) -> ScreenShot: + """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) + + @property + def height(self) -> int: + """Convenient accessor to the height size.""" + return self.size.height + + @property + def left(self) -> int: + """Convenient accessor to the left position.""" + return self.pos.left + + @property + def pixels(self) -> Pixels: + """:return list: RGB tuples.""" + 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 + + @property + def rgb(self) -> bytes: + """Compute RGB values from the BGRA raw pixels. + + :return bytes: RGB pixels. + """ + if not self.__rgb: + rgb = bytearray(self.height * self.width * 3) + raw = self.raw + rgb[::3] = raw[2::4] + rgb[1::3] = raw[1::4] + rgb[2::3] = raw[::4] + self.__rgb = bytes(rgb) + + return self.__rgb + + @property + def top(self) -> int: + """Convenient accessor to the top position.""" + return self.pos.top + + @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 diff --git a/mss/tools.py b/src/mss/tools.py similarity index 73% rename from mss/tools.py rename to src/mss/tools.py index aa79b2bf..9eb8b6f7 100644 --- a/mss/tools.py +++ b/src/mss/tools.py @@ -1,7 +1,8 @@ +"""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 @@ -9,13 +10,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional, Tuple # noqa + from pathlib import Path -def to_png(data, size, level=6, output=None): - # type: (bytes, Tuple[int, int], int, Optional[str]) -> Optional[bytes] - """ - Dump data to a PNG file. If `output` is `None`, create no file but return +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. @@ -23,17 +22,13 @@ def to_png(data, size, level=6, output=None): :param int level: PNG compression level. :param str output: Output file name. """ - # pylint: disable=too-many-locals - pack = struct.pack crc32 = zlib.crc32 width, height = size line = width * 3 png_filter = pack(">B", 0) - scanlines = b"".join( - [png_filter + data[y * line : y * line + line] for y in range(height)] - ) + scanlines = b"".join([png_filter + data[y * line : y * line + line] for y in range(height)]) magic = pack(">8B", 137, 80, 78, 71, 13, 10, 26, 10) @@ -57,7 +52,7 @@ def to_png(data, size, level=6, output=None): # 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 new file mode 100644 index 00000000..d5e2bb78 --- /dev/null +++ b/src/mss/windows.py @@ -0,0 +1,250 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +import ctypes +import sys +from ctypes import POINTER, WINFUNCTYPE, Structure, c_int, c_void_p +from ctypes.wintypes import ( + BOOL, + DOUBLE, + DWORD, + HBITMAP, + HDC, + HGDIOBJ, + HWND, + INT, + LONG, + LPARAM, + LPRECT, + RECT, + UINT, + WORD, +) +from threading import local +from typing import TYPE_CHECKING, Any + +from mss.base import MSSBase +from mss.exception import ScreenShotError + +if TYPE_CHECKING: # pragma: nocover + from mss.models import CFunctions, Monitor + from mss.screenshot import ScreenShot + +__all__ = ("MSS",) + + +CAPTUREBLT = 0x40000000 +DIB_RGB_COLORS = 0 +SRCCOPY = 0x00CC0020 + + +class BITMAPINFOHEADER(Structure): + """Information about the dimensions and color format of a DIB.""" + + _fields_ = ( + ("biSize", DWORD), + ("biWidth", LONG), + ("biHeight", LONG), + ("biPlanes", WORD), + ("biBitCount", WORD), + ("biCompression", DWORD), + ("biSizeImage", DWORD), + ("biXPelsPerMeter", LONG), + ("biYPelsPerMeter", LONG), + ("biClrUsed", DWORD), + ("biClrImportant", DWORD), + ) + + +class BITMAPINFO(Structure): + """Structure that defines the dimensions and color information for a DIB.""" + + _fields_ = (("bmiHeader", BITMAPINFOHEADER), ("bmiColors", DWORD * 3)) + + +MONITORNUMPROC = WINFUNCTYPE(INT, DWORD, DWORD, POINTER(RECT), DOUBLE) + + +# C functions that will be initialised later. +# +# Available attr: gdi32, user32. +# +# Note: keep it sorted by cfunction. +CFUNCTIONS: CFunctions = { + # Syntax: cfunction: (attr, argtypes, restype) + "BitBlt": ("gdi32", [HDC, INT, INT, INT, INT, HDC, INT, INT, DWORD], BOOL), + "CreateCompatibleBitmap": ("gdi32", [HDC, INT, INT], HBITMAP), + "CreateCompatibleDC": ("gdi32", [HDC], HDC), + "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), +} + + +class MSS(MSSBase): + """Multiple ScreenShots implementation for Microsoft Windows.""" + + __slots__ = {"_handles", "gdi32", "user32"} + + def __init__(self, /, **kwargs: Any) -> None: + """Windows initialisations.""" + super().__init__(**kwargs) + + self.user32 = ctypes.WinDLL("user32") + self.gdi32 = ctypes.WinDLL("gdi32") + 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) + + bmi = BITMAPINFO() + bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biPlanes = 1 # Always 1 + bmi.bmiHeader.biBitCount = 32 # See grab.__doc__ [2] + 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 + + def close(self) -> None: + # Clean-up + if self._handles.bmp: + self.gdi32.DeleteObject(self._handles.bmp) + self._handles.bmp = None + + if self._handles.memdc: + self.gdi32.DeleteDC(self._handles.memdc) + self._handles.memdc = None + + if self._handles.srcdc: + self.user32.ReleaseDC(0, self._handles.srcdc) + self._handles.srcdc = None + + def _set_cfunctions(self) -> None: + """Set all ctypes functions and attach them to attributes.""" + cfactory = self._cfactory + attrs = { + "gdi32": self.gdi32, + "user32": self.user32, + } + for func, (attr, argtypes, restype) in CFUNCTIONS.items(): + cfactory(attrs[attr], func, argtypes, restype) + + def _set_dpi_awareness(self) -> None: + """Set DPI awareness to capture full screen on Hi-DPI monitors.""" + version = sys.getwindowsversion()[:2] + if version >= (6, 3): + # Windows 8.1+ + # Here 2 = PROCESS_PER_MONITOR_DPI_AWARE, which means: + # per monitor DPI aware. This app checks for the DPI when it is + # created and adjusts the scale factor whenever the DPI changes. + # These applications are not automatically scaled by the system. + ctypes.windll.shcore.SetProcessDpiAwareness(2) + elif (6, 0) <= version < (6, 3): + # Windows Vista, 7, 8, and Server 2012 + self.user32.SetProcessDPIAware() + + def _monitors_impl(self) -> None: + """Get positions of monitors. It will populate self._monitors.""" + int_ = int + user32 = self.user32 + get_system_metrics = user32.GetSystemMetrics + + # All monitors + self._monitors.append( + { + "left": int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN + "top": int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN + "width": int_(get_system_metrics(78)), # SM_CXVIRTUALSCREEN + "height": int_(get_system_metrics(79)), # SM_CYVIRTUALSCREEN + }, + ) + + # Each monitor + def _callback(_monitor: int, _data: HDC, rect: LPRECT, _dc: LPARAM) -> int: + """Callback for monitorenumproc() function, it will return + a RECT with appropriate values. + """ + rct = rect.contents + self._monitors.append( + { + "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 + """ + srcdc, memdc = self._handles.srcdc, self._handles.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) + + 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) + + def _cursor_impl(self) -> ScreenShot | None: + """Retrieve all cursor data. Pixels have to be RGB.""" + return None diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mss/tests/bench_bgra2rgb.py b/src/tests/bench_bgra2rgb.py similarity index 64% rename from mss/tests/bench_bgra2rgb.py rename to src/tests/bench_bgra2rgb.py index 2560f900..6acaffb3 100644 --- a/mss/tests/bench_bgra2rgb.py +++ b/src/tests/bench_bgra2rgb.py @@ -1,6 +1,5 @@ -""" -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. 2018-03-19. @@ -31,33 +30,35 @@ import time -import mss -import numpy +import numpy as np from PIL import Image +import mss +from mss.screenshot import ScreenShot + -def mss_rgb(im): +def mss_rgb(im: ScreenShot) -> bytes: return im.rgb -def numpy_flip(im): - frame = numpy.array(im, dtype=numpy.uint8) - return numpy.flip(frame[:, :, :3], 2).tobytes() +def numpy_flip(im: ScreenShot) -> bytes: + frame = np.array(im, dtype=np.uint8) + return np.flip(frame[:, :, :3], 2).tobytes() -def numpy_slice(im): - return numpy.array(im, dtype=numpy.uint8)[..., [2, 1, 0]].tobytes() +def numpy_slice(im: ScreenShot) -> bytes: + return np.array(im, dtype=np.uint8)[..., [2, 1, 0]].tobytes() -def pil_frombytes_rgb(im): +def pil_frombytes_rgb(im: ScreenShot) -> bytes: return Image.frombytes("RGB", im.size, im.rgb).tobytes() -def pil_frombytes(im): +def pil_frombytes(im: ScreenShot) -> bytes: return Image.frombytes("RGB", im.size, im.bgra, "raw", "BGRX").tobytes() -def benchmark(): +def benchmark() -> None: with mss.mss() as sct: im = sct.grab(sct.monitors[0]) for func in ( @@ -71,7 +72,7 @@ def benchmark(): start = time.time() while (time.time() - start) <= 1: func(im) - im._ScreenShot__rgb = None + im._ScreenShot__rgb = None # type: ignore[attr-defined] count += 1 print(func.__name__.ljust(17), count) diff --git a/mss/tests/bench_general.py b/src/tests/bench_general.py similarity index 68% rename from mss/tests/bench_general.py rename to src/tests/bench_general.py index dab3fb27..100a4729 100644 --- a/mss/tests/bench_general.py +++ b/src/tests/bench_general.py @@ -1,6 +1,5 @@ -""" -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. 2018-03-19. @@ -26,32 +25,41 @@ output 139 188 +35.25 """ +from __future__ import annotations + from time import time +from typing import TYPE_CHECKING import mss import mss.tools +if TYPE_CHECKING: # pragma: nocover + from collections.abc import Callable + + from mss.base import MSSBase + from mss.screenshot import ScreenShot + -def grab(sct): +def grab(sct: MSSBase) -> ScreenShot: monitor = {"top": 144, "left": 80, "width": 1397, "height": 782} return sct.grab(monitor) -def access_rgb(sct): +def access_rgb(sct: MSSBase) -> bytes: im = grab(sct) return im.rgb -def output(sct, filename=None): +def output(sct: MSSBase, filename: str | None = None) -> None: rgb = access_rgb(sct) mss.tools.to_png(rgb, (1397, 782), output=filename) -def save(sct): +def save(sct: MSSBase) -> None: output(sct, filename="screenshot.png") -def benchmark(func): +def benchmark(func: Callable) -> None: count = 0 start = time() diff --git a/src/tests/conftest.py b/src/tests/conftest.py new file mode 100644 index 00000000..584b0b75 --- /dev/null +++ b/src/tests/conftest.py @@ -0,0 +1,82 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import os +from collections.abc import Callable, Generator +from hashlib import sha256 +from pathlib import Path +from platform import system +from zipfile import ZipFile + +import pytest + +from mss import mss +from mss.base import MSSBase +from mss.linux import xcb, xlib + + +@pytest.fixture(autouse=True) +def _no_warnings(recwarn: pytest.WarningsRecorder) -> Generator: + """Fail on warning.""" + yield + + warnings = [f"{warning.filename}:{warning.lineno} {warning.message}" for warning in recwarn] + for warning in warnings: + print(warning) + assert not warnings + + +def purge_files() -> None: + """Remove all generated files from previous runs.""" + for file in Path().glob("*.png"): + print(f"Deleting {file} ...") + file.unlink() + + for file in Path().glob("*.png.old"): + print(f"Deleting {file} ...") + file.unlink() + + +@pytest.fixture(scope="module", autouse=True) +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 sha256(data).hexdigest() == "d86ed4366d5a882cfe1345de82c87b81aef9f9bf085f4c42acb6f63f3967eccd" + return data + + +@pytest.fixture(params=["xlib", "xgetimage"] if system() == "Linux" else ["default"]) +def backend(request: pytest.FixtureRequest) -> str: + return request.param + + +@pytest.fixture +def mss_impl(backend: str) -> Callable[..., MSSBase]: + # 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. + return lambda *args, **kwargs: mss(*args, display=os.getenv("DISPLAY"), backend=backend, **kwargs) diff --git a/src/tests/res/monitor-1024x768.raw.zip b/src/tests/res/monitor-1024x768.raw.zip new file mode 100644 index 00000000..7870c0e6 Binary files /dev/null and b/src/tests/res/monitor-1024x768.raw.zip differ diff --git a/mss/tests/test_bgra_to_rgb.py b/src/tests/test_bgra_to_rgb.py similarity index 55% rename from mss/tests/test_bgra_to_rgb.py rename to src/tests/test_bgra_to_rgb.py index ee64ed70..a481c1f1 100644 --- a/mss/tests/test_bgra_to_rgb.py +++ b/src/tests/test_bgra_to_rgb.py @@ -1,20 +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. """ import pytest + from mss.base import ScreenShot -def test_bad_length(): +def test_bad_length() -> None: data = bytearray(b"789c626001000000ffff030000060005") image = ScreenShot.from_size(data, 1024, 768) - with pytest.raises(ValueError): - image.rgb + with pytest.raises(ValueError, match="attempt to assign"): + _ = image.rgb -def test_good_types(raw): +def test_good_types(raw: bytes) -> None: image = ScreenShot.from_size(bytearray(raw), 1024, 768) assert isinstance(image.raw, bytearray) assert isinstance(image.rgb, bytes) diff --git a/src/tests/test_cls_image.py b/src/tests/test_cls_image.py new file mode 100644 index 00000000..cbf02aed --- /dev/null +++ b/src/tests/test_cls_image.py @@ -0,0 +1,25 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from collections.abc import Callable +from typing import Any + +from mss.base import MSSBase +from mss.models import Monitor + + +class SimpleScreenShot: + def __init__(self, data: bytearray, monitor: Monitor, **_: Any) -> None: + self.raw = bytes(data) + self.monitor = monitor + + +def test_custom_cls_image(mss_impl: Callable[..., MSSBase]) -> None: + with mss_impl() as sct: + sct.cls_image = SimpleScreenShot # type: ignore[assignment] + mon1 = sct.monitors[1] + image = sct.grab(mon1) + assert isinstance(image, SimpleScreenShot) + assert isinstance(image.raw, bytes) + assert isinstance(image.monitor, dict) diff --git a/src/tests/test_find_monitors.py b/src/tests/test_find_monitors.py new file mode 100644 index 00000000..1194c701 --- /dev/null +++ b/src/tests/test_find_monitors.py @@ -0,0 +1,37 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from collections.abc import Callable + +from mss.base import MSSBase + + +def test_get_monitors(mss_impl: Callable[..., MSSBase]) -> None: + with mss_impl() as sct: + assert sct.monitors + + +def test_keys_aio(mss_impl: Callable[..., MSSBase]) -> None: + with mss_impl() as sct: + all_monitors = sct.monitors[0] + assert "top" in all_monitors + assert "left" in all_monitors + assert "height" in all_monitors + assert "width" in all_monitors + + +def test_keys_monitor_1(mss_impl: Callable[..., MSSBase]) -> None: + with mss_impl() as sct: + mon1 = sct.monitors[1] + assert "top" in mon1 + assert "left" in mon1 + assert "height" in mon1 + assert "width" in mon1 + + +def test_dimensions(mss_impl: Callable[..., MSSBase]) -> 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 new file mode 100644 index 00000000..53e26e4d --- /dev/null +++ b/src/tests/test_get_pixels.py @@ -0,0 +1,47 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import itertools +from collections.abc import Callable + +import pytest + +from mss.base import MSSBase, ScreenShot +from mss.exception import ScreenShotError + + +def test_grab_monitor(mss_impl: Callable[..., MSSBase]) -> None: + with mss_impl() as sct: + for mon in sct.monitors: + image = sct.grab(mon) + assert isinstance(image, ScreenShot) + assert isinstance(image.raw, bytearray) + assert isinstance(image.rgb, bytes) + + +def test_grab_part_of_screen(mss_impl: Callable[..., MSSBase]) -> 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 + assert image.height == height + + +def test_get_pixel(raw: bytes) -> None: + image = ScreenShot.from_size(bytearray(raw), 1024, 768) + assert image.width == 1024 + assert image.height == 768 + assert len(image.pixels) == 768 + assert len(image.pixels[0]) == 1024 + + assert image.pixel(0, 0) == (135, 152, 192) + assert image.pixel(image.width // 2, image.height // 2) == (0, 0, 0) + assert image.pixel(image.width - 1, image.height - 1) == (135, 152, 192) + + with pytest.raises(ScreenShotError): + image.pixel(image.width + 1, 12) diff --git a/src/tests/test_gnu_linux.py b/src/tests/test_gnu_linux.py new file mode 100644 index 00000000..82cef143 --- /dev/null +++ b/src/tests/test_gnu_linux.py @@ -0,0 +1,312 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +import ctypes.util +import platform +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 mss.linux.xcb +import mss.linux.xlib +from mss.base import MSSBase +from mss.exception import ScreenShotError + +if TYPE_CHECKING: + from collections.abc import Generator + +pyvirtualdisplay = pytest.importorskip("pyvirtualdisplay") + +PYPY = platform.python_implementation() == "PyPy" + +WIDTH = 200 +HEIGHT = 200 +DEPTH = 24 + + +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, MSSBase) + + +@pytest.mark.skipif(PYPY, reason="Failure on PyPy") +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. + """ + # GNU/Linux + monkeypatch.setattr(platform, "system", lambda: "LINUX") + with mss.mss(backend=backend) as sct: + assert isinstance(sct, MSSBase) + monkeypatch.undo() + + # macOS + monkeypatch.setattr(platform, "system", lambda: "Darwin") + # ValueError on macOS Big Sur + 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(backend=backend): + pass + + +def test_arg_display(display: str, backend: str, monkeypatch: pytest.MonkeyPatch) -> None: + # Good value + with mss.mss(display=display, backend=backend): + pass + + # Bad `display` (missing ":" in front of the number) + 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", backend=backend): + pass + + # No `DISPLAY` in envars + # 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 + + exc = excinfo.value + # Ensure it has no details. + assert not exc.details + # Ensure it can be stringified. + str(exc) + + +@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 + + +@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.xlib.MSS._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, backend="xlib"): + pass + + +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, backend: str) -> None: + monitor = {"left": -30, "top": 0, "width": WIDTH, "height": HEIGHT} + + with mss.mss(display=display, backend=backend, with_cursor=True) as sct: + # At one point, I had accidentally been reporting the resource ID as a CData object instead of the contained + # int. This is to make sure I don't repeat that mistake. That said, change this error regex if needed to keep + # up with formatting changes. + expected_err_re = ( + r"(?is)" + r"Error of failed request:\s+(8|BadMatch)\b" + r".*Major opcode of failed request:\s+73\b" + r".*Resource id in failed request:\s+[0-9]" + r".*Serial number of failed request:\s+[0-9]" + ) + + with pytest.raises(ScreenShotError, match=expected_err_re) as exc: + sct.grab(monitor) + + details = exc.value.details + assert details + assert isinstance(details, dict) + if backend == "xgetimage" and mss.linux.xcb.LIB.errors is None: + pytest.xfail("Error strings in XCB backends are only available with the xcb-util-errors library.") + assert isinstance(details["error"], str) + + errstr = str(exc.value) + assert "Match" in errstr # Xlib: "BadMatch"; XCB: "Match" + assert "GetImage" in errstr # Xlib: "X_GetImage"; XCB: "GetImage" + + if backend == "xlib": + assert not mss.linux.xlib._ERROR + + +def test__is_extension_enabled_unknown_name(display: str) -> None: + with mss.mss(display=display, backend="xlib") as sct: + assert isinstance(sct, mss.linux.xlib.MSS) # For Mypy + assert not sct._is_extension_enabled("NOEXT") + + +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, mss.linux.xlib.MSS) # For Mypy + assert hasattr(sct.xrandr, "XRRGetScreenResourcesCurrent") + fast_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResourcesCurrent") + slow_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResources") + screenshot_with_fast_fn = sct.grab(sct.monitors[1]) + + fast_spy.assert_called() + slow_spy.assert_not_called() + + assert set(screenshot_with_fast_fn.rgb) == {0} + + +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, mss.linux.xlib.MSS) # For Mypy + assert hasattr(sct.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.xrandr, "XRRGetScreenResourcesCurrent") + slow_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResources") + # If we just delete sct.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.xrandr) + del mock_xrandr.XRRGetScreenResourcesCurrent + monkeypatch.setattr(sct, "xrandr", mock_xrandr) + assert not hasattr(sct.xrandr, "XRRGetScreenResourcesCurrent") + 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_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, mss.linux.xlib.MSS) # For Mypy + monkeypatch.setattr(sct.xrandr, "XRRQueryVersion", fake_xrrqueryversion) + fast_spy = spy_and_patch(monkeypatch, sct.xrandr, "XRRGetScreenResourcesCurrent") + slow_spy = spy_and_patch(monkeypatch, sct.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, backend: str) -> None: + with mss.mss(display=display, backend=backend) as sct: + assert not hasattr(sct, "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, backend=backend, with_cursor=True) as sct: + if backend == "xlib": + assert hasattr(sct, "xfixes") + assert sct.with_cursor + screenshot_with_cursor = sct.grab(sct.monitors[1]) + + # 2 colors: black & white (default cursor is a white cross) + assert set(screenshot_with_cursor.rgb) == {0, 255} + + +@patch("mss.linux.xlib._XFIXES", new=None) +def test_with_cursor_but_not_xfixes_extension_found(display: str) -> None: + with mss.mss(display=display, backend="xlib", with_cursor=True) as sct: + assert not hasattr(sct, "xfixes") + assert not sct.with_cursor + + +def test_with_cursor_failure(display: str) -> None: + with mss.mss(display=display, backend="xlib", with_cursor=True) as sct: + assert isinstance(sct, mss.linux.xlib.MSS) # For Mypy + with ( + patch.object(sct.xfixes, "XFixesGetCursorImage", return_value=None), + pytest.raises(ScreenShotError), + ): + sct.grab(sct.monitors[1]) diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py new file mode 100644 index 00000000..d02c2b95 --- /dev/null +++ b/src/tests/test_implementation.py @@ -0,0 +1,268 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +import os +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 pytest + +import mss +from mss.__main__ import main as entry_point +from mss.base import MSSBase +from mss.exception import ScreenShotError +from mss.screenshot import ScreenShot + +if TYPE_CHECKING: # pragma: nocover + from collections.abc import Callable + + from mss.models import Monitor + +try: + from datetime import UTC +except ImportError: + # Python < 3.11 + from datetime import timezone + + UTC = timezone.utc + + +class MSS0(MSSBase): + """Nothing implemented.""" + + +class MSS1(MSSBase): + """Only `grab()` implemented.""" + + def grab(self, monitor: Monitor) -> None: # type: ignore[override] + pass + + +class MSS2(MSSBase): + """Only `monitor` implemented.""" + + @property + def monitors(self) -> list: + return [] + + +@pytest.mark.parametrize("cls", [MSS0, MSS1, MSS2]) +def test_incomplete_class(cls: type[MSSBase]) -> None: + with pytest.raises(TypeError): + cls() + + +def test_bad_monitor(mss_impl: Callable[..., MSSBase]) -> None: + with mss_impl() as sct, pytest.raises(ScreenShotError): + sct.shot(mon=222) + + +def test_repr(mss_impl: Callable[..., MSSBase]) -> None: + box = {"top": 0, "left": 0, "width": 10, "height": 10} + 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_no_backend() -> None: + with mss.mss() as sct: + assert isinstance(sct, MSSBase) + + +def test_factory_current_system(backend: str) -> None: + with mss.mss(backend=backend) as sct: + assert isinstance(sct, MSSBase) + + +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(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.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: + 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 = Path(f"monitor-{mon}.png") + assert line.endswith(filename.name) + assert filename.is_file() + filename.unlink() + + file = Path("monitor-1.png") + for opt in ("-m", "--monitor"): + main(opt, "1") + captured = capsys.readouterr() + assert captured.out.endswith(f"{file.name}\n") + assert filename.is_file() + filename.unlink() + + for opts in zip(["-m 1", "--monitor=1"], ["-q", "--quiet"]): + main(*opts) + captured = capsys.readouterr() + assert not captured.out + assert filename.is_file() + filename.unlink() + + 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 = Path(fmt.format(mon=mon, **monitor)) + assert line.endswith(filename.name) + assert filename.is_file() + filename.unlink() + + fmt = "sct_{mon}-{date:%Y-%m-%d}.png" + for opt in ("-o", "--out"): + main("-m 1", opt, fmt) + filename = 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() + + coordinates = "2,12,40,67" + filename = Path("sct-2x12_40x67.png") + for opt in ("-c", "--coordinates"): + main(opt, coordinates) + captured = capsys.readouterr() + assert captured.out.endswith(f"{filename}\n") + assert filename.is_file() + filename.unlink() + + coordinates = "2,12,40" + for opt in ("-c", "--coordinates"): + main(opt, coordinates, ret=2) + captured = capsys.readouterr() + assert captured.out == "Coordinates syntax: top, left, width, height\n" + + +@patch.object(sys, "argv", new=[]) # Prevent side effects while testing +@patch("mss.base.MSSBase.monitors", new=[]) +@pytest.mark.parametrize("quiet", [False, True]) +def test_entry_point_error(quiet: bool, capsys: pytest.CaptureFixture) -> None: + def main(*args: str) -> int: + if quiet: + args = (*args, "--quiet") + return entry_point(*args) + + if quiet: + assert main() == 1 + captured = capsys.readouterr() + assert not captured.out + assert not captured.err + else: + with pytest.raises(ScreenShotError): + main() + + +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!"))), + 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(mss_impl: Callable[..., MSSBase]) -> None: + left = 100 + top = 100 + right = 500 + lower = 500 + width = right - left # 400px width + height = lower - top # 400px height + + with mss_impl() as sct: + # PIL like + box = (left, top, right, lower) + im = sct.grab(box) + assert im.size == (width, height) + + # MSS like + box2 = {"left": left, "top": top, "width": width, "height": height} + im2 = sct.grab(box2) + assert im.size == im2.size + assert im.pos == im2.pos + assert im.rgb == im2.rgb + + +def test_grab_with_tuple_percents(mss_impl: Callable[..., MSSBase]) -> 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 + right = left + 500 # 500px + lower = top + 500 # 500px + width = right - left + height = lower - top + + # PIL like + box = (left, top, right, lower) + im = sct.grab(box) + assert im.size == (width, height) + + # MSS like + box2 = {"left": left, "top": top, "width": width, "height": height} + im2 = sct.grab(box2) + assert im.size == im2.size + assert im.pos == im2.pos + assert im.rgb == im2.rgb + + +def test_thread_safety(backend: str) -> None: + """Regression test for issue #169.""" + + def record(check: dict) -> None: + """Record for one second.""" + start_time = time.time() + while time.time() - start_time < 1: + with mss.mss(backend=backend) as sct: + sct.grab(sct.monitors[1]) + + check[threading.current_thread()] = True + + checkpoint: dict = {} + t1 = threading.Thread(target=record, args=(checkpoint,)) + t2 = threading.Thread(target=record, args=(checkpoint,)) + + t1.start() + time.sleep(0.5) + t2.start() + + t1.join() + t2.join() + + assert len(checkpoint) == 2 diff --git a/src/tests/test_issue_220.py b/src/tests/test_issue_220.py new file mode 100644 index 00000000..cb174629 --- /dev/null +++ b/src/tests/test_issue_220.py @@ -0,0 +1,63 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from functools import partial + +import pytest + +import mss + +tkinter = pytest.importorskip("tkinter") + + +@pytest.fixture +def root() -> tkinter.Tk: # type: ignore[name-defined] + try: + master = tkinter.Tk() + except RuntimeError: + pytest.skip(reason="tk.h version (8.5) doesn't match libtk.a version (8.6)") + + try: + yield master + finally: + master.destroy() + + +def take_screenshot(*, backend: str) -> None: + region = {"top": 370, "left": 1090, "width": 80, "height": 390} + with mss.mss(backend=backend) as sct: + sct.grab(region) + + +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=partial(take_screenshot, backend=backend) + ) + take_screenshot_btn.pack() + + take_screenshot_btn.invoke() + master.update_idletasks() + master.update() + + top_level_win.destroy() + master.update_idletasks() + master.update() + + +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 + btn.invoke() + + # Second screenshot: it should work too + btn.invoke() + + # Check there were no exceptions + captured = capsys.readouterr() + assert not captured.out + assert not captured.err diff --git a/src/tests/test_leaks.py b/src/tests/test_leaks.py new file mode 100644 index 00000000..f5f61677 --- /dev/null +++ b/src/tests/test_leaks.py @@ -0,0 +1,125 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import ctypes +import os +import platform +import subprocess +from collections.abc import Callable + +import pytest + +import mss + +OS = platform.system().lower() +PID = os.getpid() + + +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. + """ + 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. + """ + 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 +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(*, backend: str) -> None: + # Will always leak + sct = mss.mss(backend=backend) + sct.shot() + + +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(*, backend: str) -> None: + # Will always leak + mss.mss(backend=backend).shot() + + +def with_context_manager(*, backend: str) -> None: + with mss.mss(backend=backend) as sct: + sct.shot() + + +def regression_issue_128(*, backend: str) -> None: + """Regression test for issue #128: areas overlap.""" + 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(*, backend: str) -> None: + """Regression test for issue #135: multiple areas.""" + 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} + sct.grab(bounding_box_test) + bounding_box_score = {"top": 110, "left": 110, "width": 100, "height": 100} + sct.grab(bounding_box_score) + + +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(backend=backend): + pass + + with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.mss(backend=backend): + pass + + +@pytest.mark.skipif(OS == "darwin", reason="No possible leak on macOS.") +@pytest.mark.parametrize( + "func", + [ + # bound_instance_without_cm, + bound_instance_without_cm_but_use_close, + # unbound_instance_without_cm, + with_context_manager, + regression_issue_128, + regression_issue_135, + regression_issue_210, + ], +) +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(backend=backend) + + original_resources = monitor_func() + allocated_resources = 0 + + for _ in range(5): + func(backend=backend) + new_resources = monitor_func() + allocated_resources = max(allocated_resources, new_resources) + + assert allocated_resources <= original_resources diff --git a/src/tests/test_macos.py b/src/tests/test_macos.py new file mode 100644 index 00000000..c89ea2a8 --- /dev/null +++ b/src/tests/test_macos.py @@ -0,0 +1,84 @@ +"""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 pytest + +import mss +from mss.exception import ScreenShotError + +if platform.system().lower() != "darwin": + pytestmark = pytest.mark.skip + +import mss.darwin + + +def test_repr() -> None: + # CGPoint + point = mss.darwin.CGPoint(2.0, 1.0) + ref1 = mss.darwin.CGPoint() + ref1.x = 2.0 + ref1.y = 1.0 + assert repr(point) == repr(ref1) + + # CGSize + size = mss.darwin.CGSize(2.0, 1.0) + ref2 = mss.darwin.CGSize() + ref2.width = 2.0 + ref2.height = 1.0 + assert repr(size) == repr(ref2) + + # CGRect + rect = mss.darwin.CGRect(point, size) + ref3 = mss.darwin.CGRect() + ref3.origin.x = 2.0 + ref3.origin.y = 1.0 + ref3.size.width = 2.0 + ref3.size.height = 1.0 + assert repr(rect) == repr(ref3) + + +def test_implementation(monkeypatch: pytest.MonkeyPatch) -> None: + # No `CoreGraphics` library + version = float(".".join(platform.mac_ver()[0].split(".")[:2])) + + if version < 10.16: + monkeypatch.setattr(ctypes.util, "find_library", lambda _: None) + with pytest.raises(ScreenShotError): + mss.mss() + monkeypatch.undo() + + with mss.mss() as sct: + assert isinstance(sct, mss.darwin.MSS) # For Mypy + + # Test monitor's rotation + original = sct.monitors[1] + monkeypatch.setattr(sct.core, "CGDisplayRotation", lambda _: -90.0) + sct._monitors = [] + 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) + 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_save.py b/src/tests/test_save.py new file mode 100644 index 00000000..ae3b7cbf --- /dev/null +++ b/src/tests/test_save.py @@ -0,0 +1,83 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from collections.abc import Callable +from datetime import datetime +from pathlib import Path + +import pytest + +from mss.base import MSSBase + +try: + from datetime import UTC +except ImportError: + # Python < 3.11 + from datetime import timezone + + UTC = timezone.utc + + +def test_at_least_2_monitors(mss_impl: Callable[..., MSSBase]) -> None: + with mss_impl() as sct: + assert list(sct.save(mon=0)) + + +def test_files_exist(mss_impl: Callable[..., MSSBase]) -> None: + with mss_impl() as sct: + for filename in sct.save(): + assert Path(filename).is_file() + + assert Path(sct.shot()).is_file() + + sct.shot(mon=-1, output="fullscreen.png") + assert Path("fullscreen.png").is_file() + + +def test_callback(mss_impl: Callable[..., MSSBase]) -> None: + def on_exists(fname: str) -> None: + file = Path(fname) + if Path(file).is_file(): + file.rename(f"{file.name}.old") + + with mss_impl() as sct: + filename = sct.shot(mon=0, output="mon0.png", callback=on_exists) + assert Path(filename).is_file() + + filename = sct.shot(output="mon1.png", callback=on_exists) + assert Path(filename).is_file() + + +def test_output_format_simple(mss_impl: Callable[..., MSSBase]) -> None: + with mss_impl() as sct: + filename = sct.shot(mon=1, output="mon-{mon}.png") + assert filename == "mon-1.png" + assert Path(filename).is_file() + + +def test_output_format_positions_and_sizes(mss_impl: Callable[..., MSSBase]) -> None: + fmt = "sct-{top}x{left}_{width}x{height}.png" + with mss_impl() as sct: + filename = sct.shot(mon=1, output=fmt) + assert filename == fmt.format(**sct.monitors[1]) + assert Path(filename).is_file() + + +def test_output_format_date_simple(mss_impl: Callable[..., MSSBase]) -> None: + fmt = "sct_{mon}-{date}.png" + with mss_impl() as sct: + try: + filename = sct.shot(mon=1, output=fmt) + 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(mss_impl: Callable[..., MSSBase]) -> None: + fmt = "sct_{date:%Y-%m-%d}.png" + with mss_impl() as sct: + filename = sct.shot(mon=1, output=fmt) + assert filename == fmt.format(date=datetime.now(tz=UTC)) + assert Path(filename).is_file() diff --git a/src/tests/test_setup.py b/src/tests/test_setup.py new file mode 100644 index 00000000..c6eea223 --- /dev/null +++ b/src/tests/test_setup.py @@ -0,0 +1,147 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import platform +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": + pytestmark = pytest.mark.skip + +pytest.importorskip("build") +pytest.importorskip("twine") + +SDIST = ["python", "-m", "build", "--sdist"] +WHEEL = ["python", "-m", "build", "--wheel"] +CHECK = ["twine", "check", "--strict"] + + +def test_sdist() -> None: + output = check_output(SDIST, stderr=STDOUT, text=True) + file = f"mss-{__version__}.tar.gz" + assert f"Successfully built {file}" in output + assert "warning" not in output.lower() + + check_call([*CHECK, f"dist/{file}"]) + + with tarfile.open(f"dist/{file}", mode="r:gz") as fh: + files = sorted(fh.getnames()) + + assert files == [ + f"mss-{__version__}/.gitignore", + f"mss-{__version__}/CHANGELOG.md", + f"mss-{__version__}/CHANGES.md", + f"mss-{__version__}/CONTRIBUTORS.md", + f"mss-{__version__}/LICENSE.txt", + f"mss-{__version__}/PKG-INFO", + f"mss-{__version__}/README.md", + f"mss-{__version__}/docs/source/api.rst", + f"mss-{__version__}/docs/source/conf.py", + f"mss-{__version__}/docs/source/developers.rst", + f"mss-{__version__}/docs/source/examples.rst", + f"mss-{__version__}/docs/source/examples/callback.py", + f"mss-{__version__}/docs/source/examples/custom_cls_image.py", + f"mss-{__version__}/docs/source/examples/fps.py", + 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/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", + f"mss-{__version__}/docs/source/examples/pil.py", + f"mss-{__version__}/docs/source/examples/pil_pixels.py", + f"mss-{__version__}/docs/source/index.rst", + 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/where.rst", + f"mss-{__version__}/pyproject.toml", + f"mss-{__version__}/src/mss/__init__.py", + f"mss-{__version__}/src/mss/__main__.py", + f"mss-{__version__}/src/mss/base.py", + 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/__init__.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/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/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_find_monitors.py", + f"mss-{__version__}/src/tests/test_get_pixels.py", + f"mss-{__version__}/src/tests/test_gnu_linux.py", + f"mss-{__version__}/src/tests/test_implementation.py", + 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_save.py", + f"mss-{__version__}/src/tests/test_setup.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/xfixes.xml", + f"mss-{__version__}/src/xcbproto/xproto.xml", + ] + + +def test_wheel() -> None: + output = check_output(WHEEL, stderr=STDOUT, text=True) + file = f"mss-{__version__}-py3-none-any.whl" + assert f"Successfully built {file}" in output + assert "warning" not in output.lower() + + check_call([*CHECK, f"dist/{file}"]) + + with ZipFile(f"dist/{file}") as fh: + files = sorted(fh.namelist()) + + assert files == [ + f"mss-{__version__}.dist-info/METADATA", + f"mss-{__version__}.dist-info/RECORD", + f"mss-{__version__}.dist-info/WHEEL", + f"mss-{__version__}.dist-info/entry_points.txt", + f"mss-{__version__}.dist-info/licenses/LICENSE.txt", + "mss/__init__.py", + "mss/__main__.py", + "mss/base.py", + "mss/darwin.py", + "mss/exception.py", + "mss/factory.py", + "mss/linux/__init__.py", + "mss/linux/xcb.py", + "mss/linux/xcbgen.py", + "mss/linux/xcbhelpers.py", + "mss/linux/xgetimage.py", + "mss/linux/xlib.py", + "mss/models.py", + "mss/py.typed", + "mss/screenshot.py", + "mss/tools.py", + "mss/windows.py", + ] diff --git a/src/tests/test_tools.py b/src/tests/test_tools.py new file mode 100644 index 00000000..78feea73 --- /dev/null +++ b/src/tests/test_tools.py @@ -0,0 +1,59 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +import io +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from mss.tools import to_png + +if TYPE_CHECKING: + from collections.abc import Callable + + from mss.base import MSSBase + +WIDTH = 10 +HEIGHT = 10 + + +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[..., MSSBase]) -> None: + with mss_impl(compression_level=42) as sct, pytest.raises(Exception, match="Bad compression level"): + sct.shot() + + +@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) + assert_is_valid_png(raw=raw) + + +def test_output_file() -> None: + data = b"rgb" * WIDTH * HEIGHT + output = Path(f"{WIDTH}x{HEIGHT}.png") + to_png(data, (WIDTH, HEIGHT), output=output) + 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_is_valid_png(raw=raw) diff --git a/src/tests/test_windows.py b/src/tests/test_windows.py new file mode 100644 index 00000000..1e5763b3 --- /dev/null +++ b/src/tests/test_windows.py @@ -0,0 +1,110 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +import threading + +import pytest + +import mss +from mss.exception import ScreenShotError + +try: + import mss.windows +except ImportError: + 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 + + # Grab the area 1 + region1 = {"top": 0, "left": 0, "width": 200, "height": 200} + sct.grab(region1) + bmp1 = id(sct._handles.bmp) + + # Grab the area 2, the cached BMP 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 + + # Grab the area 2 again, the cached BMP is used + sct.grab(region2) + assert bmp2 == id(sct._handles.bmp) + + +def test_region_not_caching() -> None: + """The region to grab is not bad cached previous grab.""" + grab1 = mss.mss() + grab2 = mss.mss() + + assert isinstance(grab1, mss.windows.MSS) # For Mypy + assert isinstance(grab2, mss.windows.MSS) # For Mypy + + 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) + grab2.grab(region2) + bmp2 = id(grab2._handles.bmp) + assert bmp1 != bmp2 + + # Grab the area 1, is not bad cached BMP previous grab the area 2 + grab1.grab(region1) + bmp1 = id(grab1._handles.bmp) + assert bmp1 != bmp2 + + +def run_child_thread(loops: int) -> None: + for _ in range(loops): + 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 guaranteed. + """ + # Let thread 1 finished ahead of thread 2 + thread1 = threading.Thread(target=run_child_thread, args=(30,)) + thread2 = threading.Thread(target=run_child_thread, args=(50,)) + thread1.start() + thread2.start() + thread1.join() + 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 + for _ in range(loops): + sct.grab(bbox) + + +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 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))) + thread1.start() + thread2.start() + thread1.join() + thread2.join() diff --git a/src/tests/test_xcb.py b/src/tests/test_xcb.py new file mode 100644 index 00000000..bff4bc00 --- /dev/null +++ b/src/tests/test_xcb.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import gc +from ctypes import ( + POINTER, + Structure, + addressof, + c_int, + c_void_p, + cast, + pointer, + sizeof, +) +from types import SimpleNamespace +from typing import Any, Callable +from unittest.mock import Mock +from weakref import finalize + +import pytest + +from mss.exception import ScreenShotError +from mss.linux import xcb, xgetimage +from mss.linux.xcbhelpers import ( + XcbExtension, + array_from_xcb, + depends_on, + list_from_xcb, +) + + +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(xgetimage, "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 = xgetimage.SUPPORTED_BITS_PER_PIXEL + self.format.scanline_pad = xgetimage.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 = xgetimage.SUPPORTED_RED_MASK + self.visual.green_mask = xgetimage.SUPPORTED_GREEN_MASK + self.visual.blue_mask = xgetimage.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) + + +def test_xgetimage_visual_validation_accepts_default_setup(visual_validation_env: _VisualValidationHarness) -> None: + visual_validation_env.reset() + mss_instance = xgetimage.MSS() + try: + assert isinstance(mss_instance, xgetimage.MSS) + 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): + xgetimage.MSS() 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..487a61b3 --- /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.base import MSSBase + +np = pytest.importorskip("numpy", reason="Numpy module not available.") + + +def test_numpy(mss_impl: Callable[..., MSSBase]) -> 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/third_party/test_pil.py b/src/tests/third_party/test_pil.py new file mode 100644 index 00000000..99ea4ba5 --- /dev/null +++ b/src/tests/third_party/test_pil.py @@ -0,0 +1,67 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import itertools +from collections.abc import Callable +from pathlib import Path + +import pytest + +from mss.base import MSSBase + +Image = pytest.importorskip("PIL.Image", reason="PIL module not available.") + + +def test_pil(mss_impl: Callable[..., MSSBase]) -> None: + width, height = 16, 16 + box = {"top": 0, "left": 0, "width": width, "height": height} + with mss_impl() as sct: + sct_img = sct.grab(box) + + img = Image.frombytes("RGB", sct_img.size, sct_img.rgb) + assert img.mode == "RGB" + assert img.size == sct_img.size + + for x, y in itertools.product(range(width), range(height)): + assert img.getpixel((x, y)) == sct_img.pixel(x, y) + + output = Path("box.png") + img.save(output) + assert output.is_file() + + +def test_pil_bgra(mss_impl: Callable[..., MSSBase]) -> None: + width, height = 16, 16 + box = {"top": 0, "left": 0, "width": width, "height": height} + with mss_impl() as sct: + sct_img = sct.grab(box) + + img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") + assert img.mode == "RGB" + assert img.size == sct_img.size + + for x, y in itertools.product(range(width), range(height)): + assert img.getpixel((x, y)) == sct_img.pixel(x, y) + + output = Path("box-bgra.png") + img.save(output) + assert output.is_file() + + +def test_pil_not_16_rounded(mss_impl: Callable[..., MSSBase]) -> None: + width, height = 10, 10 + box = {"top": 0, "left": 0, "width": width, "height": height} + with mss_impl() as sct: + sct_img = sct.grab(box) + + img = Image.frombytes("RGB", sct_img.size, sct_img.rgb) + assert img.mode == "RGB" + assert img.size == sct_img.size + + for x, y in itertools.product(range(width), range(height)): + assert img.getpixel((x, y)) == sct_img.pixel(x, y) + + 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..79891dd2 --- /dev/null +++ b/src/xcbproto/README.md @@ -0,0 +1,38 @@ +# 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 moves the file to `src/mss/linux/xcbgen.py`. +4. 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..6109169b --- /dev/null +++ b/src/xcbproto/gen_xcb_to_py.py @@ -0,0 +1,1227 @@ +#!/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 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. + "NoOperation", + ], + "randr": [ + "QueryVersion", + "GetScreenResources", + "GetScreenResourcesCurrent", + "GetCrtcInfo", + ], + "render": [ + "QueryVersion", + "QueryPictFormats", + ], + "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_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 + + +StructMember = Field | Pad | ListField + + +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 "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, 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(" " * self._indent + line + "\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) -> 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("pass") + + +# 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. + members = entry.members[:] + field_entries: list[tuple[str, str] | StructMember] = [("response_type", "c_uint8")] + if members and isinstance(members[0], Field) and is_eight_bit(registry, entry.protocol, members[0].type): + member = 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 members and (isinstance(members[0], Pad) and 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 = 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 += 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 + + +# 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)) + for field in request.fields + ] + 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) + if request.reply is None: + 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, 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. + xcb_params_types = ["POINTER(Connection)", *params_types[1:]] + 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_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/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 + + + + + + + + + diff --git a/tox.ini b/tox.ini deleted file mode 100644 index bfe7ba53..00000000 --- a/tox.ini +++ /dev/null @@ -1,44 +0,0 @@ -[tox] -envlist = - lint - types - docs - py{310,39,38,37,36,35,py3} - -[testenv] -passenv = DISPLAY -alwayscopy = True -deps = - flaky - pytest - # Must pin that version to support PyPy3 - pypy3: numpy==1.15.4 - py3{9,8,7,6,5}: numpy - pillow - wheel -commands = - python -m pytest {posargs} - -[testenv:lint] -description = Code quality check -deps = - flake8 - pylint -commands = - python -m flake8 docs mss - python -m pylint mss - -[testenv:types] -description = Type annotations check -deps = - mypy -commands = - # "--platform win32" to not fail on ctypes.windll (it does not affect the overall check on other OSes) - python -m mypy --platform win32 --ignore-missing-imports mss docs/source/examples - -[testenv:docs] -description = Build the documentation -deps = sphinx -commands = - sphinx-build -d "{toxworkdir}/docs" docs/source "{toxworkdir}/docs_out" --color -W -bhtml {posargs} - python -c "print('documentation available under file://{toxworkdir}/docs_out/index.html')"