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 2767f9c2..f5c8e790 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,12 +1,3 @@ -# These are supported funding model platforms - -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -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 +github: BoboTiG +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..72ebebd0 --- /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 ffe6e427..79426812 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,24 @@ -build/ -.cache/ -dist/ -*.egg-info/ -.idea/ -MANIFEST* +# 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 ed5e59d1..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,17 +0,0 @@ -fail_fast: true - -repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: master - hooks: - - id: trailing-whitespace - - id: flake8 - - id: end-of-file-fixer - - id: check-docstring-first - - id: debug-statements - - id: check-ast - - id: no-commit-to-branch -- repo: https://github.com/ambv/black - rev: stable - hooks: - - id: black 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 fe8b982e..00000000 --- a/.travis.yml +++ /dev/null @@ -1,80 +0,0 @@ -language: python -dist: xenial - -matrix: - fast_finish: true - include: - - name: Code quality checks - os: linux - python: "3.8" - env: TOXENV=lint - - name: Types checking - os: linux - python: "3.8" - env: TOXENV=types - - name: Documentation build - os: linux - python: "3.8" - env: TOXENV=docs - - os: osx - language: shell - before_install: - - bash .travis/install.sh - env: - - PYTHON_VERSION=3.5 - - TOXENV=py35 - - name: "Python 3.6 on macOS 10.13" - os: osx - osx_image: xcode9.4 # Python 3.6.5 running on macOS 10.13 - language: shell - env: - - PYTHON_VERSION=3.6 - - TOXENV=py36 - - name: "Python 3.7 on macOS 10.14" - os: osx - osx_image: xcode10.2 # Python 3.7.3 running on macOS 10.14.3 - language: shell - env: - - PYTHON_VERSION=3.7 - - TOXENV=py37 - - os: osx - language: shell - before_install: - - bash .travis/install.sh - env: - - PYTHON_VERSION=3.8 - - TOXENV=py38 - - name: "PyPy 3.6 on GNU/Linux" - os: linux - python: "pypy3" - env: TOXENV=pypy3 - - name: "Python 3.5 on GNU/Linux" - os: linux - python: "3.5" - env: TOXENV=py35 - - name: "Python 3.6 on GNU/Linux" - os: linux - python: "3.6" - env: TOXENV=py36 - - name: "Python 3.7 on GNU/Linux" - os: linux - python: "3.7" - env: TOXENV=py37 - - name: "Python 3.8 on GNU/Linux" - os: linux - python: "3.8" - env: TOXENV=py38 - -addons: - apt: - packages: - - lsof - -services: - - xvfb - -install: - - python -m pip install --upgrade pip tox - -script: - - tox diff --git a/.travis/install.sh b/.travis/install.sh deleted file mode 100644 index 4956d58a..00000000 --- a/.travis/install.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -# Taken largely from https://stackoverflow.com/q/45257534 -# Install or upgrade to Python 3 -brew update 1>/dev/null -brew upgrade python -# Create and activate a virtualenv for conda -virtualenv -p python3 condavenv -source condavenv/bin/activate -# Grab Miniconda 3 -wget https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh -O miniconda.sh - -# Install our version of miniconda -bash miniconda.sh -b -p $HOME/miniconda -# Modify the PATH, even though this doesn't seem to be effective later on -export PATH="$HOME/miniconda/bin:$PATH" -hash -r -# Configure conda to act non-interactively -conda config --set always_yes yes --set changeps1 no -# Update conda to the latest and greatest -conda update -q conda -# Enable conda-forge for binary packages, if necessary -conda config --add channels conda-forge -# Useful for debugging any issues with conda -conda info -a -echo "Creating conda virtualenv with Python $PYTHON_VERSION" -conda create -n venv python=$PYTHON_VERSION -# For whatever reason, source is not finding the activate script unless we -# specify the full path to it -source $HOME/miniconda/bin/activate venv -# This is the Python that will be used for running tests, so we dump its -# version here to help with troubleshooting -which python -python --version 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 9d1b7340..00000000 --- a/CHANGELOG +++ /dev/null @@ -1,202 +0,0 @@ -History: - - - -5.0.0 2019/xx/xx - - 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) - -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..4d39a754 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,315 @@ +# History + +See Git checking messages for full history. + +## 10.2.0.dev0 (2025-xx-xx) +- Linux: check the server for Xrandr support version (#417) +- Linux: improve typing and error messages for X libraries (#418) +- Linux: introduce an XCB-powered backend stack with a factory in ``mss.linux`` while keeping the Xlib code as a fallback (#425) +- Linux: add the XShmGetImage backend with automatic XGetImage fallback and explicit status reporting (#431) +- :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..1f9ef079 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,238 @@ +# Technical Changes + +## 10.1.1 (2025-xx-xx) + +### linux/__init__.py +- Added an ``mss()`` factory to select between the different GNU/Linux backends. + +### linux/xlib.py +- Moved the legacy Xlib backend into the ``mss.linux.xlib`` module to be used as a fallback implementation. + +### linux/xgetimage.py +- Added an XCB-based backend that mirrors XGetImage semantics. + +### linux/xshmgetimage.py +- Added an XCB backend powered by XShmGetImage with ``shm_status`` and ``shm_fallback_reason`` attributes for diagnostics. + +## 10.1.0 (2025-08-16) + +### darwin.py +- Added `IMAGE_OPTIONS` +- Added `kCGWindowImageBoundsIgnoreFraming` +- Added `kCGWindowImageNominalResolution` +- Added `kCGWindowImageShouldBeOpaque` + +## 10.0.0 (2024-11-14) + +### base.py +- Added `OPAQUE` + +### darwin.py +- Added `MAC_VERSION_CATALINA` + +### linux.py +- Added `BITS_PER_PIXELS_32` +- Added `SUPPORTED_BITS_PER_PIXELS` + +## 9.0.0 (2023-04-18) + +### linux.py +- 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 2f91f597..00000000 --- a/CHANGES.rst +++ /dev/null @@ -1,161 +0,0 @@ -5.0.0 (2019-xx-xx) -================== - -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 5bf52f6d..00000000 --- a/CONTRIBUTORS +++ /dev/null @@ -1,50 +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 - -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 f6d3e2a1..0b055a04 100644 --- a/LICENSE +++ b/LICENSE.txt @@ -1,5 +1,5 @@ MIT License -Copyright (c) 2016-2019, 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/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 67d88a14..00000000 --- a/README.rst +++ /dev/null @@ -1,43 +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 - - -.. 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; -- 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 diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index f018e0ae..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,41 +0,0 @@ -build: off - -cache: - - '%LOCALAPPDATA%\pip\Cache' - -environment: - fast_finish: true - matrix: - - python: py35 - tox_env: py35 - python_path: c:\python35 - - python: py35-x64 - tox_env: py35 - python_path: c:\python35-x64 - - python: py36 - tox_env: py36 - python_path: c:\python36 - - python: py36-x64 - tox_env: py36 - python_path: c:\python36-x64 - - python: py37 - tox_env: py37 - python_path: c:\python37 - - python: py37-x64 - tox_env: py37 - python_path: c:\python37-x64 - - python: py38 - tox_env: py38 - python_path: c:\python38 - - python: py38-x64 - tox_env: py38 - python_path: c:\python38-x64 - -install: - - python -m pip install virtualenv - - python -m virtualenv env - - env\Scripts\activate.bat - - python -m pip install --upgrade pip tox - -test_script: - tox -e %tox_env% 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 6480938d..a385d56f 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -5,90 +5,240 @@ MSS API Classes ======= +macOS +----- + +.. module:: mss.darwin + +.. attribute:: CFUNCTIONS + + .. versionadded:: 6.1.0 + +.. function:: cgfloat + +.. class:: CGPoint + +.. class:: CGSize + +.. class:: CGRect + +.. class:: MSS + + .. attribute:: core + + .. attribute:: max_displays + GNU/Linux --------- .. module:: mss.linux -.. attribute:: ERROR +Factory function to return the appropriate backend implementation. + +.. function:: mss(backend="default", **kwargs) + + :keyword str backend: Backend name ("default", "xlib", "xgetimage", or "xshmgetimage"). + :keyword display: Display name (e.g., ":0.0") for the X server. Default is taken from the :envvar:`DISPLAY` environment variable. + :type display: str or None + :param kwargs: Additional arguments passed to the backend MSS class. + :rtype: :class:`mss.base.MSSBase` + :return: Backend-specific MSS instance. + + Factory returning a proper MSS class instance for GNU/Linux. + The backend parameter selects the implementation: + + - "default" or "xshmgetimage": XCB-based backend using XShmGetImage (default, with automatic fallback to XGetImage) + - "xgetimage": XCB-based backend using XGetImage + - "xlib": Traditional Xlib-based backend retained for environments without working XCB libraries + + .. versionadded:: 10.2.0 + The :py:attr:`backend` attribute. + +.. function:: MSS(*args, **kwargs) + + Alias for :func:`mss` for backward compatibility. + + .. versionadded:: 10.2.0 + + +Xlib Backend +^^^^^^^^^^^^ + +.. versionadded:: 10.2.0 +.. module:: mss.linux.xlib + +Legacy Xlib-based backend, kept as a fallback when XCB is unavailable. + +.. attribute:: CFUNCTIONS + + .. versionadded:: 6.1.0 + +.. attribute:: PLAINMASK + +.. attribute:: ZPIXMAP + +.. class:: Display + + Structure that serves as the connection to the X server, and that contains all the information about that X server. + +.. class:: XErrorEvent + + XErrorEvent to debug eventual errors. + +.. class:: XFixesCursorImage + + Cursor structure + +.. class:: XImage + + Description of an image as it exists in the client's memory. + +.. class:: XRRCrtcInfo + + Structure that contains CRTC information. + +.. class:: XRRModeInfo + +.. class:: XRRScreenResources + + Structure that contains arrays of XIDs that point to the available outputs and associated CRTCs. - :type: types.SimpleNamspacedict +.. class:: XWindowAttributes - The `details` attribute contains the latest Xlib or XRANDR function. It is a dict. + Attributes for the specified window. + +.. class:: MSS + + .. method:: close() + + Clean-up method. + + .. versionadded:: 8.0.0 - .. versionadded:: 5.0.0 + +XGetImage Backend +^^^^^^^^^^^^^^^^^ + +.. versionadded:: 10.2.0 +.. module:: mss.linux.xgetimage + +XCB-based backend using XGetImage protocol. .. class:: MSS - .. method:: __init__([display=None]) + XCB implementation using XGetImage for screenshot capture. - :type display: str or None - :param display: The display to use. - GNU/Linux initializations. +XShmGetImage Backend +^^^^^^^^^^^^^^^^^^^^ - .. method:: get_error_details() +.. versionadded:: 10.2.0 +.. module:: mss.linux.xshmgetimage - :rtype: Optional[dict[str, Any]] +XCB-based backend using XShmGetImage protocol with shared memory. - Get more information about the latest X server error. To use in such scenario:: +.. class:: ShmStatus - with mss.mss() as sct: - # Take a screenshot of a region out of monitor bounds - rect = {"left": -30, "top": 0, "width": 100, "height": 100} + Enum describing the availability of the X11 MIT-SHM extension used by the backend. - 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}} - """ + .. attribute:: UNKNOWN - .. versionadded:: 4.0.0 + Initial state before any capture confirms availability or failure. + + .. attribute:: AVAILABLE + + Shared-memory capture works and will continue to be used. + + .. attribute:: UNAVAILABLE + + Shared-memory capture failed; MSS will use XGetImage. + +.. class:: MSS + + XCB implementation using XShmGetImage for screenshot capture. + Falls back to XGetImage if shared memory extension is unavailable. + + .. attribute:: shm_status + + Current shared-memory availability, using :class:`mss.linux.xshmgetimage.ShmStatus`. + + .. attribute:: shm_fallback_reason + + Optional string describing why the backend fell back to XGetImage when MIT-SHM is unavailable. - .. method:: grab(monitor) +Windows +------- - :rtype: :class:`~mss.base.ScreenShot` - :raises ScreenShotError: When color depth is not 32 (rare). +.. module:: mss.windows - See :meth:`~mss.base.MSSMixin.grab()` for details. +.. attribute:: CAPTUREBLT -.. function:: error_handler(display, event) +.. attribute:: CFUNCTIONS - :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``. + .. versionadded:: 6.1.0 - Error handler passed to `X11.XSetErrorHandler()` to catch any error that can happen when calling a X11 function. - This will prevent Python interpreter crashes. +.. attribute:: DIB_RGB_COLORS - 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. +.. attribute:: SRCCOPY - .. versionadded:: 3.3.0 +.. class:: BITMAPINFOHEADER +.. class:: BITMAPINFO + +.. attribute:: MONITORNUMPROC + + .. versionadded:: 6.1.0 + +.. class:: MSS + + .. attribute:: gdi32 + + .. attribute:: user32 Methods ======= .. module:: mss.base -.. class:: MSSMixin +.. 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 @@ -96,9 +246,9 @@ Methods :param dict monitor: region's coordinates. :rtype: :class:`ScreenShot` - :raises NotImplementedError: Subclasses need to implement this. Retrieve screen pixels for a given *region*. + Subclasses need to implement this. .. note:: @@ -110,18 +260,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}``. @@ -136,14 +286,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:: @@ -158,7 +308,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) @@ -194,7 +344,7 @@ Methods Properties ========== -.. class:: mss.base.MSSMixin +.. class:: mss.base.MSSBase .. attribute:: monitors @@ -215,6 +365,8 @@ Properties - ``width``: the width - ``height``: the height + Subclasses need to implement this. + :rtype: list[dict[str, int]] .. class:: mss.base.ScreenShot @@ -235,25 +387,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()` @@ -267,19 +419,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 001aeb4d..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-2019, 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 = "5.0.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..1d4636f3 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,33 +31,33 @@ 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 `_:: +To ensure the code quality is correct enough:: - $ TOXENV=lint tox + $ python -m pip install -e '.[dev]' + $ ./check.sh -Static Type Checking -==================== +Documentation +============= -To check type annotation using `mypy `_:: +To build the documentation, simply type:: - $ TOXENV=types tox + $ python -m pip install -e '.[docs]' + $ sphinx-build -d docs docs/source docs_out --color -W -bhtml -Documentation -============= +XCB Code Generator +================== -To build the documentation, simply type:: +.. versionadded:: 10.2.0 - $ TOXENV=docs tox +The GNU/Linux XCB backends rely on generated ctypes bindings. If you need to +add new XCB requests or types, do **not** edit ``src/mss/linux/xcbgen.py`` by +hand. Instead, follow the workflow described in ``src/xcbproto/README.md``, +which explains how to update ``gen_xcb_to_py.py`` and regenerate the bindings. diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 137715fd..bf10caea 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,16 +77,43 @@ 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 +GNU/Linux XShm backend +---------------------- + +Select the XShmGetImage backend explicitly and inspect whether it is active or +falling back to XGetImage: + +.. literalinclude:: examples/linux_xshm_backend.py + :lines: 7- + +.. versionadded:: 10.2.0 + PIL === @@ -94,7 +121,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 +131,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 +143,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 +156,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 +167,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/linux_xshm_backend.py b/docs/source/examples/linux_xshm_backend.py new file mode 100644 index 00000000..3d2f22bf --- /dev/null +++ b/docs/source/examples/linux_xshm_backend.py @@ -0,0 +1,17 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. + +Select the XShmGetImage backend explicitly and inspect its status. +""" + +from mss.linux.xshmgetimage import MSS as mss + +with mss() as sct: + screenshot = sct.grab(sct.monitors[1]) + print(f"Captured screenshot dimensions: {screenshot.size.width}x{screenshot.size.height}") + + print(f"shm_status: {sct.shm_status.name}") + if sct.shm_fallback_reason: + print(f"Falling back to XGetImage because: {sct.shm_fallback_reason}") + else: + print("MIT-SHM capture active.") 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 228eceb3..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; - - 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 b4dc029a..d003f790 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -11,6 +11,12 @@ Quite simple:: $ python -m pip install -U --user mss +Conda Package +------------- + +The module is also available from Conda:: + + $ conda install -c conda-forge python-mss From Sources ============ 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..75dc3c22 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -5,21 +5,26 @@ Usage Import ====== -So MSS can be used as simply as:: +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 +On GNU/Linux you can also import a specific backend (see :ref:`backends`) +directly when you need a particular implementation, for example:: + + from mss.linux.xshmgetimage import MSS as mss + Instance ======== @@ -46,21 +51,59 @@ 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. + + +.. _backends: + +Backends +-------- + +Some platforms have multiple ways to take screenshots. In MSS, these are known as *backends*. The :py:func:`mss` functions will normally autodetect which one is appropriate for your situation, but you can override this if you want. For instance, you may know that your specific situation requires a particular backend. + +If you want to choose a particular backend, you can use the :py:attr:`backend` keyword to :py:func:`mss`:: + + with mss(backend="xgetimage") as sct: + ... + +Alternatively, you can also directly import the backend you want to use:: + + from mss.linux.xgetimage import MSS as mss + +Currently, only the GNU/Linux implementation has multiple backends. These are described in their own section below. GNU/Linux --------- -On GNU/Linux, you can specify which display to use (useful for distant screenshots via SSH):: - - with mss(display=":0.0") as sct: - # ... +Display +^^^^^^^ -A more specific example to only target GNU/Linux: +On GNU/Linux, the default display is taken from the :envvar:`DISPLAY` environment variable. You can instead specify which display to use (useful for distant screenshots via SSH) using the ``display`` keyword: .. literalinclude:: examples/linux_display_keyword.py - :lines: 8- + :lines: 7- + + +Backends +^^^^^^^^ + +The GNU/Linux implementation has multiple backends (see :ref:`backends`), or ways it can take screenshots. The :py:func:`mss.mss` and :py:func:`mss.linux.mss` functions will normally autodetect which one is appropriate, but you can override this if you want. + +There are three available backends. + +:py:mod:`xshmgetimage` (default) + The fastest backend, based on :c:func:`xcb_shm_get_image`. It is roughly three times faster than :py:mod:`xgetimage` + and is used automatically. When the MIT-SHM extension is unavailable (for example on remote SSH displays), it + transparently falls back to :py:mod:`xgetimage` so you can always request it safely. + +:py:mod:`xgetimage` + A highly-compatible, but slower, backend based on :c:func:`xcb_get_image`. Use this explicitly only when you know + that :py:mod:`xshmgetimage` cannot operate in your environment. + +:py:mod:`xlib` + The legacy backend powered by :c:func:`XGetImage`. It is kept solely for systems where XCB libraries are + unavailable and no new features are being added to it. Command Line @@ -72,6 +115,31 @@ You can use ``mss`` via the CLI:: Or via direct call from Python:: - python -m mss --help + $ python -m mss --help + usage: mss [-h] [-c COORDINATES] [-l {0,1,2,3,4,5,6,7,8,9}] [-m MONITOR] + [-o OUTPUT] [--with-cursor] [-q] [-b BACKEND] [-v] + + options: + -h, --help show this help message and exit + -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 + -b, --backend BACKEND + platform-specific backend to use + (Linux: default/xlib/xgetimage/xshmgetimage; macOS/Windows: default) + --with-cursor include the cursor + -q, --quiet do not print created files + -v, --version show program's version number and exit .. versionadded:: 3.1.1 + +.. versionadded:: 8.0.0 + ``--with-cursor`` to include the cursor in screenshots. + +.. versionadded:: 10.2.0 + ``--backend`` to force selecting the backend to use. diff --git a/docs/source/where.rst b/docs/source/where.rst index 64920ee2..83a69bd7 100644 --- a/docs/source/where.rst +++ b/docs/source/where.rst @@ -3,44 +3,34 @@ 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. +- Nvidia; +- `Airtest `_, a cross-platform UI automation framework for aames and apps; +- `Automation Framework `_, a Batmans utility; - `DeepEye `_, a deep vision-based software library for autonomous and advanced driver-assistance systems; - `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 -========= - -- `Philips Hue Lights Ambiance `_; -- `Flexx Python UI toolkit `_; -- `NativeShot `_ (Mozilla Firefox module); diff --git a/mss/__init__.py b/mss/__init__.py deleted file mode 100644 index de457c1a..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__ = "5.0.0" -__author__ = "Mickaël 'Tiger-222' Schoentgen" -__copyright__ = """ - Copyright (c) 2013-2019, 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/__main__.py b/mss/__main__.py deleted file mode 100644 index 939e7ae0..00000000 --- a/mss/__main__.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -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 - - -def main(args=None): - # type: (Optional[List[str]]) -> int - """ Main logic. """ - - cli_args = ArgumentParser() - cli_args.add_argument( - "-c", - "--coordinates", - default="", - type=str, - help="the part of the screen to capture: top, left, width, height", - ) - cli_args.add_argument( - "-l", - "--level", - default=6, - type=int, - choices=list(range(10)), - help="the PNG compression level", - ) - cli_args.add_argument( - "-m", "--monitor", default=0, type=int, help="the monitor to screen shot" - ) - cli_args.add_argument( - "-o", "--output", default="monitor-{mon}.png", help="the output file name" - ) - cli_args.add_argument( - "-q", - "--quiet", - default=False, - action="store_true", - help="do not print created files", - ) - cli_args.add_argument("-v", "--version", action="version", version=__version__) - - options = cli_args.parse_args(args) - kwargs = {"mon": options.monitor, "output": options.output} - if options.coordinates: - try: - top, left, width, height = options.coordinates.split(",") - except ValueError: - print("Coordinates syntax: top, left, width, height") - return 2 - - kwargs["mon"] = { - "top": int(top), - "left": int(left), - "width": int(width), - "height": int(height), - } - if options.output == "monitor-{mon}.png": - kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png" - - try: - with mss() as sct: - if options.coordinates: - output = kwargs["output"].format(**kwargs["mon"]) - sct_img = sct.grab(kwargs["mon"]) - to_png(sct_img.rgb, sct_img.size, level=options.level, output=output) - if not options.quiet: - print(os.path.realpath(output)) - else: - for file_name in sct.save(**kwargs): - if not options.quiet: - print(os.path.realpath(file_name)) - return 0 - except ScreenShotError: - return 1 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/mss/base.py b/mss/base.py deleted file mode 100644 index 4cbe4cf5..00000000 --- a/mss/base.py +++ /dev/null @@ -1,160 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -from datetime import datetime -from typing import TYPE_CHECKING - -from .exception import ScreenShotError -from .screenshot import ScreenShot -from .tools import to_png - -if TYPE_CHECKING: - from typing import Any, Callable, Iterator, List, Optional, Type # noqa - - from .models import Monitor, Monitors # noqa - - -class MSSMixin: - """ 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: () -> MSSMixin - """ For the cool call `with MSS() as mss:`. """ - - return self - - def __exit__(self, *_): - """ For the cool call `with MSS() as mss:`. """ - - self.close() - - def close(self): - # type: () -> None - """ Clean-up. """ - - def grab(self, monitor): - # type: (Monitor) -> ScreenShot - """ - Retrieve screen pixels for a given monitor. - - :param monitor: The coordinates and size of the box to capture. - See :meth:`monitors ` for object details. - :return :class:`ScreenShot `. - """ - - raise NotImplementedError("Subclasses need to implement this!") - - @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 - } - - Note: monitor can be a tuple like PIL.Image.grab() accepts, - it must be converted to the appropriate dict. - """ - - raise NotImplementedError("Subclasses need to implement this!") - - 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: - 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/darwin.py b/mss/darwin.py deleted file mode 100644 index cd4b9d49..00000000 --- a/mss/darwin.py +++ /dev/null @@ -1,230 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import ctypes -import ctypes.util -import sys -from typing import TYPE_CHECKING - -from .base import MSSMixin -from .exception import ScreenShotError -from .screenshot import Size - -if TYPE_CHECKING: - from typing import Any, List, Type, Union # noqa - - from .models import Monitor, Monitors # noqa - from .screenshot import ScreenShot # noqa - -__all__ = ("MSS",) - - -def cgfloat(): - # type: () -> Union[Type[ctypes.c_double], Type[ctypes.c_float]] - """ Get the appropriate value for a float. """ - - return ctypes.c_double if sys.maxsize > 2 ** 32 else ctypes.c_float - - -class CGPoint(ctypes.Structure): - """ Structure that contains coordinates of a rectangle. """ - - _fields_ = [("x", cgfloat()), ("y", cgfloat())] - - def __repr__(self): - return "{}(left={} top={})".format(type(self).__name__, self.x, self.y) - - -class CGSize(ctypes.Structure): - """ Structure that contains dimensions of an rectangle. """ - - _fields_ = [("width", cgfloat()), ("height", cgfloat())] - - def __repr__(self): - return "{}(width={} height={})".format( - type(self).__name__, self.width, self.height - ) - - -class CGRect(ctypes.Structure): - """ Structure that contains information about a rectangle. """ - - _fields_ = [("origin", CGPoint), ("size", CGSize)] - - def __repr__(self): - return "{}<{} {}>".format(type(self).__name__, self.origin, self.size) - - -class MSS(MSSMixin): - """ - Multiple ScreenShots implementation for macOS. - It uses intensively the CoreGraphics library. - """ - - __slots__ = {"core", "max_displays"} - - def __init__(self, **_): - """ macOS initialisations. """ - - super().__init__() - - self.max_displays = 32 - - coregraphics = ctypes.util.find_library("CoreGraphics") - if not coregraphics: - raise ScreenShotError("No CoreGraphics library found.") - self.core = ctypes.cdll.LoadLibrary(coregraphics) - - self._set_cfunctions() - - def _set_cfunctions(self): - # type: () -> None - """ Set all ctypes functions and attach them to attributes. """ - - def cfactory(func, argtypes, restype): - # type: (str, List[Any], Any) -> None - """ Factorize ctypes creations. """ - self._cfactory( - attr=self.core, func=func, argtypes=argtypes, restype=restype - ) - - uint32 = ctypes.c_uint32 - void = ctypes.c_void_p - size_t = ctypes.c_size_t - pointer = ctypes.POINTER - - cfactory( - func="CGGetActiveDisplayList", - argtypes=[uint32, pointer(uint32), pointer(uint32)], - restype=ctypes.c_int32, - ) - cfactory(func="CGDisplayBounds", argtypes=[uint32], restype=CGRect) - cfactory(func="CGRectStandardize", argtypes=[CGRect], restype=CGRect) - cfactory(func="CGRectUnion", argtypes=[CGRect, CGRect], restype=CGRect) - cfactory(func="CGDisplayRotation", argtypes=[uint32], restype=ctypes.c_float) - cfactory( - func="CGWindowListCreateImage", - argtypes=[CGRect, uint32, uint32, uint32], - restype=void, - ) - cfactory(func="CGImageGetWidth", argtypes=[void], restype=size_t) - cfactory(func="CGImageGetHeight", argtypes=[void], restype=size_t) - cfactory(func="CGImageGetDataProvider", argtypes=[void], restype=void) - cfactory(func="CGDataProviderCopyData", argtypes=[void], restype=void) - cfactory(func="CFDataGetBytePtr", argtypes=[void], restype=void) - cfactory(func="CFDataGetLength", argtypes=[void], restype=ctypes.c_uint64) - cfactory(func="CGImageGetBytesPerRow", argtypes=[void], restype=size_t) - cfactory(func="CGImageGetBitsPerPixel", argtypes=[void], restype=size_t) - cfactory(func="CGDataProviderRelease", argtypes=[void], restype=void) - cfactory(func="CFRelease", argtypes=[void], restype=void) - - @property - def monitors(self): - # type: () -> Monitors - """ Get positions of monitors (see parent class). """ - - if not self._monitors: - int_ = int - core = self.core - - # All monitors - # We need to update the value with every single monitor found - # using CGRectUnion. Else we will end with infinite values. - all_monitors = CGRect() - self._monitors.append({}) - - # Each monitors - display_count = ctypes.c_uint32(0) - active_displays = (ctypes.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"} - 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"]: - 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 - all_monitors = core.CGRectUnion(all_monitors, rect) - - # Set the AiO monitor's values - self._monitors[0] = { - "left": int_(all_monitors.origin.x), - "top": int_(all_monitors.origin.y), - "width": int_(all_monitors.size.width), - "height": int_(all_monitors.size.height), - } - - return self._monitors - - def grab(self, monitor): - # type: (Monitor) -> ScreenShot - """ - See :meth:`MSSMixin.grab ` for full details. - """ - - # pylint: disable=too-many-locals - - # 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], - } - - core = self.core - rect = CGRect( - (monitor["left"], monitor["top"]), (monitor["width"], monitor["height"]) - ) - - image_ref = core.CGWindowListCreateImage(rect, 1, 0, 0) - if not image_ref: - raise ScreenShotError("CoreGraphics.CGWindowListCreateImage() failed.") - - width = int(core.CGImageGetWidth(image_ref)) - height = int(core.CGImageGetHeight(image_ref)) - prov = copy_data = None - try: - prov = core.CGImageGetDataProvider(image_ref) - copy_data = core.CGDataProviderCopyData(prov) - data_ref = core.CFDataGetBytePtr(copy_data) - buf_len = core.CFDataGetLength(copy_data) - raw = ctypes.cast(data_ref, ctypes.POINTER(ctypes.c_ubyte * buf_len)) - data = bytearray(raw.contents) - - # Remove padding per row - bytes_per_row = int(core.CGImageGetBytesPerRow(image_ref)) - bytes_per_pixel = int(core.CGImageGetBitsPerPixel(image_ref)) - bytes_per_pixel = (bytes_per_pixel + 7) // 8 - - if bytes_per_pixel * width != bytes_per_row: - cropped = bytearray() - for row in range(height): - start = row * bytes_per_row - end = start + width * bytes_per_pixel - cropped.extend(data[start:end]) - data = cropped - finally: - if prov: - core.CGDataProviderRelease(prov) - if copy_data: - core.CFRelease(copy_data) - - return self.cls_image(data, monitor, size=Size(width, height)) 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 1d5b123d..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 MSSMixin # noqa - - -def mss(**kwargs): - # type: (Any) -> MSSMixin - """ 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 5bd41c82..00000000 --- a/mss/linux.py +++ /dev/null @@ -1,426 +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 -from types import SimpleNamespace -from typing import TYPE_CHECKING - -from .base import MSSMixin -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(ctypes.Structure): - """ - Structure that serves as the connection to the X server - and that contains all the information about that X server. - """ - - -class Event(ctypes.Structure): - """ - XErrorEvent to debug eventual errors. - https://tronche.com/gui/x/xlib/event-handling/protocol-errors/default-handlers.html - """ - - _fields_ = [ - ("type", ctypes.c_int), - ("display", ctypes.POINTER(Display)), - ("serial", ctypes.c_ulong), - ("error_code", ctypes.c_ubyte), - ("request_code", ctypes.c_ubyte), - ("minor_code", ctypes.c_ubyte), - ("resourceid", ctypes.c_void_p), - ] - - -class XWindowAttributes(ctypes.Structure): - """ Attributes for the specified window. """ - - _fields_ = [ - ("x", ctypes.c_int32), - ("y", ctypes.c_int32), - ("width", ctypes.c_int32), - ("height", ctypes.c_int32), - ("border_width", ctypes.c_int32), - ("depth", ctypes.c_int32), - ("visual", ctypes.c_ulong), - ("root", ctypes.c_ulong), - ("class", ctypes.c_int32), - ("bit_gravity", ctypes.c_int32), - ("win_gravity", ctypes.c_int32), - ("backing_store", ctypes.c_int32), - ("backing_planes", ctypes.c_ulong), - ("backing_pixel", ctypes.c_ulong), - ("save_under", ctypes.c_int32), - ("colourmap", ctypes.c_ulong), - ("mapinstalled", ctypes.c_uint32), - ("map_state", ctypes.c_uint32), - ("all_event_masks", ctypes.c_ulong), - ("your_event_mask", ctypes.c_ulong), - ("do_not_propagate_mask", ctypes.c_ulong), - ("override_redirect", ctypes.c_int32), - ("screen", ctypes.c_ulong), - ] - - -class XImage(ctypes.Structure): - """ - Description of an image as it exists in the client's memory. - https://tronche.com/gui/x/xlib/graphics/images.html - """ - - _fields_ = [ - ("width", ctypes.c_int), - ("height", ctypes.c_int), - ("xoffset", ctypes.c_int), - ("format", ctypes.c_int), - ("data", ctypes.c_void_p), - ("byte_order", ctypes.c_int), - ("bitmap_unit", ctypes.c_int), - ("bitmap_bit_order", ctypes.c_int), - ("bitmap_pad", ctypes.c_int), - ("depth", ctypes.c_int), - ("bytes_per_line", ctypes.c_int), - ("bits_per_pixel", ctypes.c_int), - ("red_mask", ctypes.c_ulong), - ("green_mask", ctypes.c_ulong), - ("blue_mask", ctypes.c_ulong), - ] - - -class XRRModeInfo(ctypes.Structure): - """ Voilà, voilà. """ - - -class XRRScreenResources(ctypes.Structure): - """ - Structure that contains arrays of XIDs that point to the - available outputs and associated CRTCs. - """ - - _fields_ = [ - ("timestamp", ctypes.c_ulong), - ("configTimestamp", ctypes.c_ulong), - ("ncrtc", ctypes.c_int), - ("crtcs", ctypes.POINTER(ctypes.c_long)), - ("noutput", ctypes.c_int), - ("outputs", ctypes.POINTER(ctypes.c_long)), - ("nmode", ctypes.c_int), - ("modes", ctypes.POINTER(XRRModeInfo)), - ] - - -class XRRCrtcInfo(ctypes.Structure): - """ Structure that contains CRTC information. """ - - _fields_ = [ - ("timestamp", ctypes.c_ulong), - ("x", ctypes.c_int), - ("y", ctypes.c_int), - ("width", ctypes.c_int), - ("height", ctypes.c_int), - ("mode", ctypes.c_long), - ("rotation", ctypes.c_int), - ("noutput", ctypes.c_int), - ("outputs", ctypes.POINTER(ctypes.c_long)), - ("rotations", ctypes.c_ushort), - ("npossible", ctypes.c_int), - ("possible", ctypes.POINTER(ctypes.c_long)), - ] - - -@ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(Display), ctypes.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) - - -class MSS(MSSMixin): - """ - Multiple ScreenShots implementation for GNU/Linux. - It uses intensively the Xlib and its Xrandr extension. - """ - - __slots__ = {"drawable", "root", "xlib", "xrandr"} - - # Class attribute to store the display opened with XOpenDisplay(). - # Instancied one time to prevent resource leak. - display = None - - 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: - 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() - - if not MSS.display: - MSS.display = self.xlib.XOpenDisplay(display) - self.root = self.xlib.XDefaultRootWindow(MSS.display) - - # Fix for XRRGetScreenResources and XGetImage: - # expected LP_Display instance instead of LP_XWindowAttributes - self.drawable = ctypes.cast(self.root, ctypes.POINTER(Display)) - - def _set_cfunctions(self): - """ - Set all ctypes functions and attach them to attributes. - See https://tronche.com/gui/x/xlib/function-index.html for details. - """ - - def cfactory(func, argtypes, restype, attr=self.xlib): - # type: (str, List[Any], Any, Any) -> None - """ Factorize ctypes creations. """ - self._cfactory( - attr=attr, - errcheck=validate, - func=func, - argtypes=argtypes, - restype=restype, - ) - - void = ctypes.c_void_p - c_int = ctypes.c_int - uint = ctypes.c_uint - ulong = ctypes.c_ulong - c_long = ctypes.c_long - char_p = ctypes.c_char_p - pointer = ctypes.POINTER - - cfactory("XSetErrorHandler", [void], c_int) - cfactory("XGetErrorText", [pointer(Display), c_int, char_p, c_int], void) - cfactory("XOpenDisplay", [char_p], pointer(Display)) - cfactory("XDefaultRootWindow", [pointer(Display)], pointer(XWindowAttributes)) - cfactory( - "XGetWindowAttributes", - [pointer(Display), pointer(XWindowAttributes), pointer(XWindowAttributes)], - c_int, - ) - cfactory( - "XGetImage", - [ - pointer(Display), - pointer(Display), - c_int, - c_int, - uint, - uint, - ulong, - c_int, - ], - pointer(XImage), - ) - cfactory("XDestroyImage", [pointer(XImage)], void) - - # 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: - cfactory( - "XRRGetScreenResourcesCurrent", - [pointer(Display), pointer(Display)], - pointer(XRRScreenResources), - attr=self.xrandr, - ) - except AttributeError: - cfactory( - "XRRGetScreenResources", - [pointer(Display), pointer(Display)], - pointer(XRRScreenResources), - attr=self.xrandr, - ) - self.xrandr.XRRGetScreenResourcesCurrent = self.xrandr.XRRGetScreenResources - - cfactory( - "XRRGetCrtcInfo", - [pointer(Display), pointer(XRRScreenResources), c_long], - pointer(XRRCrtcInfo), - attr=self.xrandr, - ) - cfactory( - "XRRFreeScreenResources", - [pointer(XRRScreenResources)], - void, - attr=self.xrandr, - ) - cfactory("XRRFreeCrtcInfo", [pointer(XRRCrtcInfo)], void, attr=self.xrandr) - - 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( - MSS.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 - - @property - def monitors(self): - # type: () -> Monitors - """ Get positions of monitors (see parent class property). """ - - if not self._monitors: - display = MSS.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 - mon = xrandr.XRRGetScreenResourcesCurrent(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) - - return self._monitors - - def grab(self, monitor): - # type: (Monitor) -> ScreenShot - """ Retrieve all pixels from a monitor. Pixels have to be RGB. """ - - # 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], - } - - ximage = self.xlib.XGetImage( - MSS.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, - ctypes.POINTER( - ctypes.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 0e810169..00000000 --- a/mss/screenshot.py +++ /dev/null @@ -1,157 +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: - raise ScreenShotError( - "Pixel location ({}, {}) is out of range.".format(coord_x, coord_y) - ) diff --git a/mss/tools.py b/mss/tools.py deleted file mode 100644 index 4b0c040b..00000000 --- a/mss/tools.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import struct -import zlib -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Optional, Tuple # noqa - - -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 - the whole PNG data. - - :param bytes data: RGBRGB...RGB data. - :param tuple size: The (width, height) pair. - :param int level: PNG compression level. - :param str output: Output file name. - """ - - width, height = size - line = width * 3 - png_filter = struct.pack(">B", 0) - scanlines = b"".join( - [png_filter + data[y * line : y * line + line] for y in range(height)] - ) - - magic = struct.pack(">8B", 137, 80, 78, 71, 13, 10, 26, 10) - - # Header: size, marker, data, CRC32 - ihdr = [b"", b"IHDR", b"", b""] - ihdr[2] = struct.pack(">2I5B", width, height, 8, 2, 0, 0, 0) - ihdr[3] = struct.pack(">I", zlib.crc32(b"".join(ihdr[1:3])) & 0xFFFFFFFF) - ihdr[0] = struct.pack(">I", len(ihdr[2])) - - # Data: size, marker, data, CRC32 - idat = [b"", b"IDAT", zlib.compress(scanlines, level), b""] - idat[3] = struct.pack(">I", zlib.crc32(b"".join(idat[1:3])) & 0xFFFFFFFF) - idat[0] = struct.pack(">I", len(idat[2])) - - # Footer: size, marker, None, CRC32 - iend = [b"", b"IEND", b"", b""] - iend[3] = struct.pack(">I", zlib.crc32(iend[1]) & 0xFFFFFFFF) - iend[0] = struct.pack(">I", len(iend[2])) - - if not output: - # Returns raw bytes of the whole PNG data - return magic + b"".join(ihdr + idat + iend) - - with open(output, "wb") as fileh: - fileh.write(magic) - fileh.write(b"".join(ihdr)) - fileh.write(b"".join(idat)) - fileh.write(b"".join(iend)) - - return None diff --git a/mss/windows.py b/mss/windows.py deleted file mode 100644 index 7ce24240..00000000 --- a/mss/windows.py +++ /dev/null @@ -1,293 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import sys -import ctypes -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 MSSMixin -from .exception import ScreenShotError - -if TYPE_CHECKING: - from typing import Any # noqa - - from .models import Monitor, Monitors # noqa - from .screenshot import ScreenShot # noqa - -__all__ = ("MSS",) - - -CAPTUREBLT = 0x40000000 -DIB_RGB_COLORS = 0 -SRCCOPY = 0x00CC0020 - - -class BITMAPINFOHEADER(ctypes.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(ctypes.Structure): - """ - Structure that defines the dimensions and color information for a DIB. - """ - - _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", DWORD * 3)] - - -class MSS(MSSMixin): - """ Multiple ScreenShots implementation for Microsoft Windows. """ - - __slots__ = {"_bbox", "_bmi", "_data", "gdi32", "monitorenumproc", "user32"} - - # Class attributes instancied one time to prevent resource leaks. - bmp = None - memdc = None - srcdc = None - - def __init__(self, **_): - # type: (Any) -> None - """ Windows initialisations. """ - - super().__init__() - - self.monitorenumproc = ctypes.WINFUNCTYPE( - INT, DWORD, DWORD, ctypes.POINTER(RECT), DOUBLE - ) - - 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] - - if not MSS.srcdc or not MSS.memdc: - MSS.srcdc = self.user32.GetWindowDC(0) - MSS.memdc = self.gdi32.CreateCompatibleDC(MSS.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. """ - - void = ctypes.c_void_p - pointer = ctypes.POINTER - - self._cfactory( - attr=self.user32, func="GetSystemMetrics", argtypes=[INT], restype=INT - ) - self._cfactory( - attr=self.user32, - func="EnumDisplayMonitors", - argtypes=[HDC, void, self.monitorenumproc, LPARAM], - restype=BOOL, - ) - self._cfactory( - attr=self.user32, func="GetWindowDC", argtypes=[HWND], restype=HDC - ) - - self._cfactory( - attr=self.gdi32, func="GetDeviceCaps", argtypes=[HWND, INT], restype=INT - ) - self._cfactory( - attr=self.gdi32, func="CreateCompatibleDC", argtypes=[HDC], restype=HDC - ) - self._cfactory( - attr=self.gdi32, - func="CreateCompatibleBitmap", - argtypes=[HDC, INT, INT], - restype=HBITMAP, - ) - self._cfactory( - attr=self.gdi32, - func="SelectObject", - argtypes=[HDC, HGDIOBJ], - restype=HGDIOBJ, - ) - self._cfactory( - attr=self.gdi32, - func="BitBlt", - argtypes=[HDC, INT, INT, INT, INT, HDC, INT, INT, DWORD], - restype=BOOL, - ) - self._cfactory( - attr=self.gdi32, func="DeleteObject", argtypes=[HGDIOBJ], restype=INT - ) - self._cfactory( - attr=self.gdi32, - func="GetDIBits", - argtypes=[HDC, HBITMAP, UINT, UINT, void, pointer(BITMAPINFO), UINT], - restype=BOOL, - ) - - 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() - - @property - def monitors(self): - # type: () -> Monitors - """ Get positions of monitors (see parent class). """ - - if not 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 = self.monitorenumproc(_callback) - user32.EnumDisplayMonitors(0, 0, callback, 0) - - return self._monitors - - def grab(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, then we transform to RGB. - 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 - """ - - # 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], - } - - srcdc, memdc = MSS.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..f13c3151 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,195 @@ +[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.19.1", + "ruff==0.14.9", + "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 + "N811", # importing constant (MSS) as non-constant (mss) +] +"src/tests/*" = [ + "FBT001", # boolean-typed positional argument in function definition + "PLR2004", # magic value used in comparison + "S101", # use of `assert` detected + "S602", # `subprocess` call with `shell=True` + "S603", # `subprocess` call: check for execution of untrusted input + "S607", # `subprocess` call without explicit paths + "SLF001", # private member accessed +] + +[tool.ruff.per-file-target-version] +"src/xcbproto/*" = "py312" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index d65bc01e..00000000 --- a/setup.cfg +++ /dev/null @@ -1,59 +0,0 @@ -[metadata] -name = mss -version = 5.0.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_file = 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.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3 :: Only - 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 - -[bdist_wheel] -universal = 1 - -[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 - --no-print-logs - -r fE - -v 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..21ae18f8 --- /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.2.0.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/src/mss/__main__.py b/src/mss/__main__.py new file mode 100644 index 00000000..7d884fc6 --- /dev/null +++ b/src/mss/__main__.py @@ -0,0 +1,113 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import os.path +import platform +import sys +from argparse import ArgumentError, ArgumentParser + +from mss import __version__ +from mss.exception import ScreenShotError +from mss.factory import mss +from mss.tools import to_png + + +def _backend_cli_choices() -> list[str]: + os_name = platform.system().lower() + if os_name == "darwin": + from mss import darwin # noqa: PLC0415 + + return list(darwin.BACKENDS) + if os_name == "linux": + from mss import linux # noqa: PLC0415 + + return list(linux.BACKENDS) + if os_name == "windows": + from mss import windows # noqa: PLC0415 + + return list(windows.BACKENDS) + return ["default"] + + +def main(*args: str) -> int: + """Main logic.""" + backend_choices = _backend_cli_choices() + + cli_args = ArgumentParser(prog="mss", exit_on_error=False) + cli_args.add_argument( + "-c", + "--coordinates", + default="", + type=str, + help="the part of the screen to capture: top, left, width, height", + ) + cli_args.add_argument( + "-l", + "--level", + default=6, + type=int, + choices=list(range(10)), + help="the PNG compression level", + ) + 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", + default=False, + action="store_true", + help="do not print created files", + ) + cli_args.add_argument( + "-b", "--backend", default="default", choices=backend_choices, help="platform-specific backend to use" + ) + cli_args.add_argument("-v", "--version", action="version", version=__version__) + + try: + options = cli_args.parse_args(args or None) + except ArgumentError as e: + # By default, parse_args will print and the error and exit. We + # return instead of exiting, to make unit testing easier. + cli_args.print_usage(sys.stderr) + print(f"{cli_args.prog}: error: {e}", file=sys.stderr) + return 2 + kwargs = {"mon": options.monitor, "output": options.output} + if options.coordinates: + try: + top, left, width, height = options.coordinates.split(",") + except ValueError: + print("Coordinates syntax: top, left, width, height") + return 2 + + kwargs["mon"] = { + "top": int(top), + "left": int(left), + "width": int(width), + "height": int(height), + } + if options.output == "monitor-{mon}.png": + kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png" + + try: + with mss(with_cursor=options.with_cursor, backend=options.backend) as sct: + if options.coordinates: + output = kwargs["output"].format(**kwargs["mon"]) + sct_img = sct.grab(kwargs["mon"]) + to_png(sct_img.rgb, sct_img.size, level=options.level, output=output) + if not options.quiet: + print(os.path.realpath(output)) + else: + for file_name in sct.save(**kwargs): + if not options.quiet: + print(os.path.realpath(file_name)) + return 0 + except ScreenShotError: + if options.quiet: + return 1 + raise + + +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..2abfcbd3 --- /dev/null +++ b/src/mss/base.py @@ -0,0 +1,276 @@ +"""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 + + # Prior to 3.11, Python didn't have the Self type. typing_extensions does, but we don't want to depend on it. + try: + from typing import Self + except ImportError: # pragma: nocover + try: + from typing_extensions import Self + except ImportError: # pragma: nocover + Self = Any # type: ignore[assignment] + +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) -> Self: + """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/src/mss/darwin.py b/src/mss/darwin.py new file mode 100644 index 00000000..5d723b98 --- /dev/null +++ b/src/mss/darwin.py @@ -0,0 +1,219 @@ +"""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_ubyte, c_uint32, c_uint64, c_void_p +from platform import mac_ver +from typing import TYPE_CHECKING, Any + +from mss.base import MSSBase +from mss.exception import ScreenShotError +from mss.screenshot import ScreenShot, Size + +if TYPE_CHECKING: # pragma: nocover + from mss.models import CFunctions, Monitor + +__all__ = ("MSS",) + +BACKENDS = ["default"] + +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[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.""" + + _fields_ = (("x", cgfloat()), ("y", cgfloat())) + + 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.""" + + _fields_ = (("width", cgfloat()), ("height", cgfloat())) + + 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.""" + + _fields_ = (("origin", CGPoint), ("size", CGSize)) + + def __repr__(self) -> str: + return f"{type(self).__name__}<{self.origin} {self.size}>" + + +# C functions that will be initialised later. +# +# Available attr: core. +# +# Note: keep it sorted by cfunction. +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), + "CFDataGetBytePtr": ("core", [c_void_p], c_void_p), + "CFDataGetLength": ("core", [c_void_p], c_uint64), + "CFRelease": ("core", [c_void_p], c_void_p), + "CGDataProviderRelease": ("core", [c_void_p], c_void_p), + "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), + "CGImageGetHeight": ("core", [c_void_p], int), + "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), +} + + +class MSS(MSSBase): + """Multiple ScreenShots implementation for macOS. + It uses intensively the CoreGraphics library. + """ + + __slots__ = {"core", "max_displays"} + + def __init__(self, /, **kwargs: Any) -> None: + """MacOS initialisations.""" + super().__init__(**kwargs) + + self.max_displays = kwargs.get("max_displays", 32) + + self._init_library() + self._set_cfunctions() + + def _init_library(self) -> None: + """Load the CoreGraphics library.""" + version = float(".".join(mac_ver()[0].split(".")[:2])) + if version < MAC_VERSION_CATALINA: + coregraphics = ctypes.util.find_library("CoreGraphics") + else: + # macOS Big Sur and newer + coregraphics = "/System/Library/Frameworks/CoreGraphics.framework/Versions/Current/CoreGraphics" + + if not coregraphics: + msg = "No CoreGraphics library found." + raise ScreenShotError(msg) + self.core = ctypes.cdll.LoadLibrary(coregraphics) + + 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(attrs[attr], func, argtypes, restype) + + def _monitors_impl(self) -> None: + """Get positions of monitors. It will populate self._monitors.""" + int_ = int + core = self.core + + # All monitors + # We need to update the value with every single monitor found + # using CGRectUnion. Else we will end with infinite values. + all_monitors = CGRect() + self._monitors.append({}) + + # 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)) + 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 + + # 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 + all_monitors = core.CGRectUnion(all_monitors, rect) + + # Set the AiO monitor's values + self._monitors[0] = { + "left": int_(all_monitors.origin.x), + "top": int_(all_monitors.origin.y), + "width": int_(all_monitors.size.width), + "height": int_(all_monitors.size.height), + } + + def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: + """Retrieve all pixels from a monitor. Pixels have to be RGB.""" + core = self.core + rect = CGRect((monitor["left"], monitor["top"]), (monitor["width"], monitor["height"])) + + image_ref = core.CGWindowListCreateImage(rect, 1, 0, IMAGE_OPTIONS) + if not image_ref: + msg = "CoreGraphics.CGWindowListCreateImage() failed." + raise ScreenShotError(msg) + + width = core.CGImageGetWidth(image_ref) + height = core.CGImageGetHeight(image_ref) + prov = copy_data = None + try: + prov = core.CGImageGetDataProvider(image_ref) + copy_data = core.CGDataProviderCopyData(prov) + data_ref = core.CFDataGetBytePtr(copy_data) + buf_len = core.CFDataGetLength(copy_data) + raw = ctypes.cast(data_ref, POINTER(c_ubyte * buf_len)) + data = bytearray(raw.contents) + + # Remove padding per row + bytes_per_row = core.CGImageGetBytesPerRow(image_ref) + bytes_per_pixel = core.CGImageGetBitsPerPixel(image_ref) + bytes_per_pixel = (bytes_per_pixel + 7) // 8 + + if bytes_per_pixel * width != bytes_per_row: + cropped = bytearray() + for row in range(height): + start = row * bytes_per_row + end = start + width * bytes_per_pixel + cropped.extend(data[start:end]) + data = cropped + finally: + if prov: + core.CGDataProviderRelease(prov) + if copy_data: + 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..933310d7 --- /dev/null +++ b/src/mss/factory.py @@ -0,0 +1,41 @@ +"""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 + + # Linux has its own factory to choose the backend. + 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..8426abfd --- /dev/null +++ b/src/mss/linux/__init__.py @@ -0,0 +1,42 @@ +from typing import Any + +from mss.base import MSSBase +from mss.exception import ScreenShotError + +BACKENDS = ["default", "xlib", "xgetimage", "xshmgetimage"] + + +def mss(backend: str = "default", **kwargs: Any) -> MSSBase: + """Factory returning a proper MSS class instance. + + It examines the options provided, and chooses the most adapted MSS + class to take screenshots. It then proxies its arguments to the + class for instantiation. + + Currently, the only option used is the "backend" flag. Future + versions will look at other options as well. + """ + backend = backend.lower() + if backend == "xlib": + from . import xlib # noqa: PLC0415 + + return xlib.MSS(**kwargs) + if backend == "xgetimage": + from . import xgetimage # noqa: PLC0415 + + # Note that the xshmgetimage backend will automatically fall back to XGetImage calls if XShmGetImage isn't + # available. The only reason to use the xgetimage backend is if the user already knows that XShmGetImage + # isn't going to be supported. + return xgetimage.MSS(**kwargs) + if backend in {"default", "xshmgetimage"}: + from . import xshmgetimage # noqa: PLC0415 + + return xshmgetimage.MSS(**kwargs) + assert backend not in BACKENDS # noqa: S101 + 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/base.py b/src/mss/linux/base.py new file mode 100644 index 00000000..489863ce --- /dev/null +++ b/src/mss/linux/base.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from mss.base import MSSBase +from mss.exception import ScreenShotError + +from . import xcb +from .xcb import LIB + +if TYPE_CHECKING: + from mss.models import Monitor + from mss.screenshot import ScreenShot + +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 MSSXCBBase(MSSBase): + """Base class for XCB-based screenshot implementations. + + This class provides common XCB initialization and monitor detection logic + that can be shared across different XCB screenshot methods (XGetImage, + XShmGetImage, XComposite, etc.). + """ + + def __init__(self, /, **kwargs: Any) -> None: # noqa: PLR0912 + """Initialize an XCB connection and validate the display configuration. + + Args: + **kwargs: Keyword arguments, including optional 'display' for X11 display string. + + Raises: + ScreenShotError: If the display configuration is not supported. + """ + 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) + + # Get the connection setup information that was included when we connected. + xcb_setup = xcb.get_setup(self.conn) + screens = xcb.setup_roots(xcb_setup) + self.pref_screen = screens[pref_screen_num] + self.root = self.drawable = self.pref_screen.root + + # We don't probe the XFixes presence or version until we need it. + self._xfixes_ready: bool | None = None + + # Probe the visuals (and related information), and make sure that our drawable is in an acceptable format. + # These iterations and tests don't involve any traffic with the server; it's all stuff that was included in + # the connection setup. Effectively all modern setups will be acceptable, but we verify to be sure. + + # Currently, we assume that the drawable we're capturing is the root; when we add single-window capture, + # we'll have to ask the server for its depth and visual. + assert self.root == self.drawable # noqa: S101 + self.drawable_depth = self.pref_screen.root_depth + self.drawable_visual_id = self.pref_screen.root_visual.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(self.pref_screen): + if xcb_depth.depth == self.drawable_depth: + break + else: + msg = "Internal error: drawable's depth not found in screen's supported depths" + raise ScreenShotError(msg) + for visual_info in xcb.depth_visuals(xcb_depth): + if visual_info.visual_id.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: + """Close the XCB connection.""" + 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 = xcb.get_extension_data(self.conn, LIB.randr_id) + 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 _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 = xcb.get_extension_data(self.conn, LIB.xfixes_id) + if not xfixes_ext_data.present: + return False + + reply = xcb.xfixes_query_version(self.conn, xcb.XFIXES_MAJOR_VERSION, xcb.XFIXES_MINOR_VERSION) + # We can work with 2.0 and later, but not sure about the actual minimum version we can use. That's ok; + # everything these days is much more modern. + return (reply.major_version, reply.minor_version) >= (2, 0) + + def _cursor_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) + + def _grab_impl_xgetimage(self, monitor: Monitor, /) -> ScreenShot: + """Retrieve all pixels from a monitor using GetImage. + + This is used by the XGetImage backend, and also the XShmGetImage + backend in fallback mode. + """ + + 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) diff --git a/src/mss/linux/xcb.py b/src/mss/linux/xcb.py new file mode 100644 index 00000000..f599e2a7 --- /dev/null +++ b/src/mss/linux/xcb.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from ctypes import _Pointer, c_int + +from . import xcbgen + +# We import these just so they're re-exported to our users. +# ruff: noqa: F401, TC001 +from .xcbgen import ( + RANDR_MAJOR_VERSION, + RANDR_MINOR_VERSION, + RENDER_MAJOR_VERSION, + RENDER_MINOR_VERSION, + SHM_MAJOR_VERSION, + SHM_MINOR_VERSION, + XFIXES_MAJOR_VERSION, + XFIXES_MINOR_VERSION, + Atom, + BackingStore, + Colormap, + Depth, + DepthIterator, + Drawable, + Format, + GetGeometryReply, + GetImageReply, + GetPropertyReply, + ImageFormat, + ImageOrder, + Keycode, + Pixmap, + 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, + ShmCreateSegmentReply, + ShmGetImageReply, + ShmQueryVersionReply, + ShmSeg, + Timestamp, + VisualClass, + Visualid, + Visualtype, + Window, + XfixesGetCursorImageReply, + XfixesQueryVersionReply, + depth_visuals, + get_geometry, + get_image, + get_image_data, + get_property, + get_property_value, + no_operation, + randr_get_crtc_info, + randr_get_crtc_info_outputs, + randr_get_crtc_info_possible, + randr_get_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, + shm_attach_fd, + shm_create_segment, + shm_create_segment_reply_fds, + shm_detach, + shm_get_image, + shm_query_version, + xfixes_get_cursor_image, + xfixes_get_cursor_image_cursor_image, + xfixes_query_version, +) + +# These are also here to re-export. +from .xcbhelpers import LIB, XID, Connection, QueryExtensionReply, XcbExtension, XError + +XCB_CONN_ERROR = 1 +XCB_CONN_CLOSED_EXT_NOTSUPPORTED = 2 +XCB_CONN_CLOSED_MEM_INSUFFICIENT = 3 +XCB_CONN_CLOSED_REQ_LEN_EXCEED = 4 +XCB_CONN_CLOSED_PARSE_ERR = 5 +XCB_CONN_CLOSED_INVALID_SCREEN = 6 +XCB_CONN_CLOSED_FDPASSING_FAILED = 7 + +# I don't know of error descriptions for the XCB connection errors being accessible through a library (a la strerror), +# and the ones in xcb.h's comments aren't too great, so I wrote these. +XCB_CONN_ERRMSG = { + XCB_CONN_ERROR: "connection lost or could not be established", + XCB_CONN_CLOSED_EXT_NOTSUPPORTED: "extension not supported", + XCB_CONN_CLOSED_MEM_INSUFFICIENT: "memory exhausted", + XCB_CONN_CLOSED_REQ_LEN_EXCEED: "request length longer than server accepts", + XCB_CONN_CLOSED_PARSE_ERR: "display is unset or invalid (check $DISPLAY)", + XCB_CONN_CLOSED_INVALID_SCREEN: "server does not have a screen matching the requested display", + XCB_CONN_CLOSED_FDPASSING_FAILED: "could not pass file descriptor", +} + + +#### High-level XCB function wrappers + + +def get_extension_data( + xcb_conn: Connection | _Pointer[Connection], ext: XcbExtension | _Pointer[XcbExtension] +) -> QueryExtensionReply: + """Get extension data for the given extension. + + Returns the extension data, which includes whether the extension is present + and its opcode information. + """ + reply_p = LIB.xcb.xcb_get_extension_data(xcb_conn, ext) + return reply_p.contents + + +def prefetch_extension_data( + xcb_conn: Connection | _Pointer[Connection], ext: XcbExtension | _Pointer[XcbExtension] +) -> None: + """Prefetch extension data for the given extension. + + This is a performance hint to XCB to fetch the extension data + asynchronously. + """ + LIB.xcb.xcb_prefetch_extension_data(xcb_conn, ext) + + +def generate_id(xcb_conn: Connection | _Pointer[Connection]) -> XID: + """Generate a new unique X resource ID. + + Returns an XID that can be used to create new X resources. + """ + return LIB.xcb.xcb_generate_id(xcb_conn) + + +def get_setup(xcb_conn: Connection | _Pointer[Connection]) -> Setup: + """Get the connection setup information. + + Returns the setup structure containing information about the X server, + including available screens, pixmap formats, etc. + """ + setup_p = LIB.xcb.xcb_get_setup(xcb_conn) + return setup_p.contents + + +# Connection management + + +def initialize() -> None: + LIB.initialize(callbacks=[xcbgen.initialize]) + + +def connect(display: str | bytes | None = None) -> tuple[Connection, int]: + if isinstance(display, str): + display = display.encode("utf-8") + + initialize() + pref_screen_num = c_int() + conn_p = LIB.xcb.xcb_connect(display, pref_screen_num) + + # We still get a connection object even if the connection fails. + conn_err = LIB.xcb.xcb_connection_has_error(conn_p) + if conn_err != 0: + # XCB won't free its connection structures until we disconnect, even in the event of an error. + LIB.xcb.xcb_disconnect(conn_p) + msg = "Cannot connect to display: " + conn_errmsg = XCB_CONN_ERRMSG.get(conn_err) + if conn_errmsg: + msg += conn_errmsg + else: + msg += f"error code {conn_err}" + raise XError(msg) + + # Prefetch extension data for all extensions we support to populate XCB's internal cache. + prefetch_extension_data(conn_p, LIB.randr_id) + prefetch_extension_data(conn_p, LIB.render_id) + prefetch_extension_data(conn_p, LIB.shm_id) + prefetch_extension_data(conn_p, LIB.xfixes_id) + + 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..6fba72b3 --- /dev/null +++ b/src/mss/linux/xcbgen.py @@ -0,0 +1,913 @@ +# Auto-generated by gen_xcb_to_py.py - do not edit manually. + +# Since many of the generated functions have many parameters, we disable the pylint warning about too many arguments. +# ruff: noqa: PLR0913 + +from __future__ import annotations + +from ctypes import ( + POINTER, + Array, + Structure, + _Pointer, + c_char, + c_int, + c_int16, + c_uint8, + c_uint16, + c_uint32, +) +from enum import IntEnum + +from mss.linux.xcbhelpers import ( + LIB, + XID, + Connection, + VoidCookie, + array_from_xcb, + initialize_xcb_typed_func, + list_from_xcb, +) + +RANDR_MAJOR_VERSION = 1 +RANDR_MINOR_VERSION = 6 +RENDER_MAJOR_VERSION = 0 +RENDER_MINOR_VERSION = 11 +SHM_MAJOR_VERSION = 1 +SHM_MINOR_VERSION = 2 +XFIXES_MAJOR_VERSION = 6 +XFIXES_MINOR_VERSION = 0 + +# Enum classes + + +class 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 ShmQueryVersionReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("shared_pixmaps", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("major_version", c_uint16), + ("minor_version", c_uint16), + ("uid", c_uint16), + ("gid", c_uint16), + ("pixmap_format", c_uint8), + ("pad0", c_uint8 * 15), + ) + + +class ShmSeg(XID): + pass + + +class ShmGetImageReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("depth", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("visual", Visualid), + ("size", c_uint32), + ("pad0", c_uint8 * 16), + ) + + +class ShmCreateSegmentReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("nfd", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("pad0", c_uint8 * 24), + ) + + +class XfixesQueryVersionReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("major_version", c_uint32), + ("minor_version", c_uint32), + ("pad1", c_uint8 * 16), + ) + + +class XfixesGetCursorImageReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8 * 1), + ("sequence", c_uint16), + ("length", c_uint32), + ("x", c_int16), + ("y", c_int16), + ("width", c_uint16), + ("height", c_uint16), + ("xhot", c_uint16), + ("yhot", c_uint16), + ("cursor_serial", c_uint32), + ("pad1", c_uint8 * 8), + ) + + +def depth_visuals(r: Depth) -> Array[Visualtype]: + return array_from_xcb(LIB.xcb.xcb_depth_visuals, LIB.xcb.xcb_depth_visuals_length, r) + + +def screen_allowed_depths(r: Screen) -> list[Depth]: + return list_from_xcb(LIB.xcb.xcb_screen_allowed_depths_iterator, LIB.xcb.xcb_depth_next, r) + + +def setup_vendor(r: Setup) -> Array[c_char]: + return array_from_xcb(LIB.xcb.xcb_setup_vendor, LIB.xcb.xcb_setup_vendor_length, r) + + +def setup_pixmap_formats(r: Setup) -> Array[Format]: + return array_from_xcb(LIB.xcb.xcb_setup_pixmap_formats, LIB.xcb.xcb_setup_pixmap_formats_length, r) + + +def setup_roots(r: Setup) -> list[Screen]: + return list_from_xcb(LIB.xcb.xcb_setup_roots_iterator, LIB.xcb.xcb_screen_next, r) + + +def get_image_data(r: GetImageReply) -> Array[c_uint8]: + return array_from_xcb(LIB.xcb.xcb_get_image_data, LIB.xcb.xcb_get_image_data_length, r) + + +def get_property_value(r: GetPropertyReply) -> Array[c_char]: + return array_from_xcb(LIB.xcb.xcb_get_property_value, LIB.xcb.xcb_get_property_value_length, r) + + +def randr_get_screen_resources_crtcs(r: RandrGetScreenResourcesReply) -> Array[RandrCrtc]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_crtcs, LIB.randr.xcb_randr_get_screen_resources_crtcs_length, r + ) + + +def randr_get_screen_resources_outputs(r: RandrGetScreenResourcesReply) -> Array[RandrOutput]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_outputs, LIB.randr.xcb_randr_get_screen_resources_outputs_length, r + ) + + +def randr_get_screen_resources_modes(r: RandrGetScreenResourcesReply) -> Array[RandrModeInfo]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_modes, LIB.randr.xcb_randr_get_screen_resources_modes_length, r + ) + + +def randr_get_screen_resources_names(r: RandrGetScreenResourcesReply) -> Array[c_uint8]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_names, LIB.randr.xcb_randr_get_screen_resources_names_length, r + ) + + +def randr_get_screen_resources_current_crtcs(r: RandrGetScreenResourcesCurrentReply) -> Array[RandrCrtc]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_current_crtcs, + LIB.randr.xcb_randr_get_screen_resources_current_crtcs_length, + r, + ) + + +def randr_get_screen_resources_current_outputs(r: RandrGetScreenResourcesCurrentReply) -> Array[RandrOutput]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_current_outputs, + LIB.randr.xcb_randr_get_screen_resources_current_outputs_length, + r, + ) + + +def randr_get_screen_resources_current_modes(r: RandrGetScreenResourcesCurrentReply) -> Array[RandrModeInfo]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_current_modes, + LIB.randr.xcb_randr_get_screen_resources_current_modes_length, + r, + ) + + +def randr_get_screen_resources_current_names(r: RandrGetScreenResourcesCurrentReply) -> Array[c_uint8]: + return array_from_xcb( + LIB.randr.xcb_randr_get_screen_resources_current_names, + LIB.randr.xcb_randr_get_screen_resources_current_names_length, + r, + ) + + +def randr_get_crtc_info_outputs(r: RandrGetCrtcInfoReply) -> Array[RandrOutput]: + return array_from_xcb( + LIB.randr.xcb_randr_get_crtc_info_outputs, LIB.randr.xcb_randr_get_crtc_info_outputs_length, r + ) + + +def randr_get_crtc_info_possible(r: RandrGetCrtcInfoReply) -> Array[RandrOutput]: + return array_from_xcb( + LIB.randr.xcb_randr_get_crtc_info_possible, LIB.randr.xcb_randr_get_crtc_info_possible_length, r + ) + + +def render_pictdepth_visuals(r: RenderPictdepth) -> Array[RenderPictvisual]: + return array_from_xcb(LIB.render.xcb_render_pictdepth_visuals, LIB.render.xcb_render_pictdepth_visuals_length, r) + + +def render_pictscreen_depths(r: RenderPictscreen) -> list[RenderPictdepth]: + return list_from_xcb(LIB.render.xcb_render_pictscreen_depths_iterator, LIB.render.xcb_render_pictdepth_next, r) + + +def render_query_pict_formats_formats(r: RenderQueryPictFormatsReply) -> Array[RenderPictforminfo]: + return array_from_xcb( + LIB.render.xcb_render_query_pict_formats_formats, LIB.render.xcb_render_query_pict_formats_formats_length, r + ) + + +def render_query_pict_formats_screens(r: RenderQueryPictFormatsReply) -> list[RenderPictscreen]: + return list_from_xcb( + LIB.render.xcb_render_query_pict_formats_screens_iterator, LIB.render.xcb_render_pictscreen_next, r + ) + + +def render_query_pict_formats_subpixels(r: RenderQueryPictFormatsReply) -> Array[c_uint32]: + return array_from_xcb( + LIB.render.xcb_render_query_pict_formats_subpixels, LIB.render.xcb_render_query_pict_formats_subpixels_length, r + ) + + +def xfixes_get_cursor_image_cursor_image(r: XfixesGetCursorImageReply) -> Array[c_uint32]: + return array_from_xcb( + LIB.xfixes.xcb_xfixes_get_cursor_image_cursor_image, + LIB.xfixes.xcb_xfixes_get_cursor_image_cursor_image_length, + r, + ) + + +def shm_create_segment_reply_fds(c: Connection | _Pointer[Connection], r: ShmCreateSegmentReply) -> _Pointer[c_int]: + return LIB.shm.xcb_shm_create_segment_reply_fds(c, r) + + +def get_geometry(c: Connection, drawable: Drawable) -> GetGeometryReply: + return LIB.xcb.xcb_get_geometry(c, drawable).reply(c) + + +def get_image( + c: Connection, + format_: c_uint8 | int, + drawable: Drawable, + x: c_int16 | int, + y: c_int16 | int, + width: c_uint16 | int, + height: c_uint16 | int, + plane_mask: c_uint32 | int, +) -> GetImageReply: + return LIB.xcb.xcb_get_image(c, format_, drawable, x, y, width, height, plane_mask).reply(c) + + +def get_property( + c: Connection, + delete: c_uint8 | int, + window: Window, + property_: Atom, + type_: Atom, + long_offset: c_uint32 | int, + long_length: c_uint32 | int, +) -> GetPropertyReply: + return LIB.xcb.xcb_get_property(c, delete, window, property_, type_, long_offset, long_length).reply(c) + + +def no_operation(c: Connection) -> None: + return LIB.xcb.xcb_no_operation_checked(c).check(c) + + +def randr_query_version( + c: Connection, major_version: c_uint32 | int, minor_version: c_uint32 | int +) -> RandrQueryVersionReply: + return LIB.randr.xcb_randr_query_version(c, major_version, minor_version).reply(c) + + +def randr_get_screen_resources(c: Connection, window: Window) -> RandrGetScreenResourcesReply: + return LIB.randr.xcb_randr_get_screen_resources(c, window).reply(c) + + +def randr_get_screen_resources_current(c: Connection, window: Window) -> RandrGetScreenResourcesCurrentReply: + return LIB.randr.xcb_randr_get_screen_resources_current(c, window).reply(c) + + +def randr_get_crtc_info(c: Connection, crtc: RandrCrtc, config_timestamp: Timestamp) -> RandrGetCrtcInfoReply: + return LIB.randr.xcb_randr_get_crtc_info(c, crtc, config_timestamp).reply(c) + + +def render_query_version( + c: Connection, client_major_version: c_uint32 | int, client_minor_version: c_uint32 | int +) -> RenderQueryVersionReply: + return LIB.render.xcb_render_query_version(c, client_major_version, client_minor_version).reply(c) + + +def render_query_pict_formats(c: Connection) -> RenderQueryPictFormatsReply: + return LIB.render.xcb_render_query_pict_formats(c).reply(c) + + +def shm_query_version(c: Connection) -> ShmQueryVersionReply: + return LIB.shm.xcb_shm_query_version(c).reply(c) + + +def shm_get_image( + c: Connection, + drawable: Drawable, + x: c_int16 | int, + y: c_int16 | int, + width: c_uint16 | int, + height: c_uint16 | int, + plane_mask: c_uint32 | int, + format_: c_uint8 | int, + shmseg: ShmSeg, + offset: c_uint32 | int, +) -> ShmGetImageReply: + return LIB.shm.xcb_shm_get_image(c, drawable, x, y, width, height, plane_mask, format_, shmseg, offset).reply(c) + + +def shm_attach_fd(c: Connection, shmseg: ShmSeg, shm_fd: c_int | int, read_only: c_uint8 | int) -> None: + return LIB.shm.xcb_shm_attach_fd_checked(c, shmseg, shm_fd, read_only).check(c) + + +def shm_create_segment( + c: Connection, shmseg: ShmSeg, size: c_uint32 | int, read_only: c_uint8 | int +) -> ShmCreateSegmentReply: + return LIB.shm.xcb_shm_create_segment(c, shmseg, size, read_only).reply(c) + + +def shm_detach(c: Connection, shmseg: ShmSeg) -> None: + return LIB.shm.xcb_shm_detach_checked(c, shmseg).check(c) + + +def xfixes_query_version( + c: Connection, client_major_version: c_uint32 | int, client_minor_version: c_uint32 | int +) -> XfixesQueryVersionReply: + return LIB.xfixes.xcb_xfixes_query_version(c, client_major_version, client_minor_version).reply(c) + + +def xfixes_get_cursor_image(c: Connection) -> XfixesGetCursorImageReply: + return LIB.xfixes.xcb_xfixes_get_cursor_image(c).reply(c) + + +def initialize() -> None: # noqa: PLR0915 + LIB.xcb.xcb_depth_next.argtypes = (POINTER(DepthIterator),) + LIB.xcb.xcb_depth_next.restype = None + LIB.xcb.xcb_screen_next.argtypes = (POINTER(ScreenIterator),) + LIB.xcb.xcb_screen_next.restype = None + LIB.xcb.xcb_setup_next.argtypes = (POINTER(SetupIterator),) + LIB.xcb.xcb_setup_next.restype = None + LIB.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 + LIB.shm.xcb_shm_create_segment_reply_fds.argtypes = ( + POINTER(Connection), + POINTER(ShmCreateSegmentReply), + ) + LIB.shm.xcb_shm_create_segment_reply_fds.restype = POINTER(c_int) + initialize_xcb_typed_func(LIB.xcb, "xcb_get_geometry", [POINTER(Connection), Drawable], GetGeometryReply) + initialize_xcb_typed_func( + LIB.xcb, + "xcb_get_image", + [POINTER(Connection), c_uint8, Drawable, c_int16, c_int16, c_uint16, c_uint16, c_uint32], + GetImageReply, + ) + initialize_xcb_typed_func( + LIB.xcb, + "xcb_get_property", + [POINTER(Connection), c_uint8, Window, Atom, Atom, c_uint32, c_uint32], + GetPropertyReply, + ) + LIB.xcb.xcb_no_operation_checked.argtypes = (POINTER(Connection),) + LIB.xcb.xcb_no_operation_checked.restype = VoidCookie + initialize_xcb_typed_func( + LIB.randr, "xcb_randr_query_version", [POINTER(Connection), c_uint32, c_uint32], RandrQueryVersionReply + ) + initialize_xcb_typed_func( + LIB.randr, "xcb_randr_get_screen_resources", [POINTER(Connection), Window], RandrGetScreenResourcesReply + ) + initialize_xcb_typed_func( + LIB.randr, + "xcb_randr_get_screen_resources_current", + [POINTER(Connection), Window], + RandrGetScreenResourcesCurrentReply, + ) + initialize_xcb_typed_func( + LIB.randr, "xcb_randr_get_crtc_info", [POINTER(Connection), RandrCrtc, Timestamp], RandrGetCrtcInfoReply + ) + initialize_xcb_typed_func( + LIB.render, "xcb_render_query_version", [POINTER(Connection), c_uint32, c_uint32], RenderQueryVersionReply + ) + initialize_xcb_typed_func( + LIB.render, "xcb_render_query_pict_formats", [POINTER(Connection)], RenderQueryPictFormatsReply + ) + initialize_xcb_typed_func(LIB.shm, "xcb_shm_query_version", [POINTER(Connection)], ShmQueryVersionReply) + initialize_xcb_typed_func( + LIB.shm, + "xcb_shm_get_image", + [POINTER(Connection), Drawable, c_int16, c_int16, c_uint16, c_uint16, c_uint32, c_uint8, ShmSeg, c_uint32], + ShmGetImageReply, + ) + LIB.shm.xcb_shm_attach_fd_checked.argtypes = ( + POINTER(Connection), + ShmSeg, + c_int, + c_uint8, + ) + LIB.shm.xcb_shm_attach_fd_checked.restype = VoidCookie + initialize_xcb_typed_func( + LIB.shm, "xcb_shm_create_segment", [POINTER(Connection), ShmSeg, c_uint32, c_uint8], ShmCreateSegmentReply + ) + LIB.shm.xcb_shm_detach_checked.argtypes = ( + POINTER(Connection), + ShmSeg, + ) + LIB.shm.xcb_shm_detach_checked.restype = VoidCookie + initialize_xcb_typed_func( + LIB.xfixes, "xcb_xfixes_query_version", [POINTER(Connection), c_uint32, c_uint32], XfixesQueryVersionReply + ) + initialize_xcb_typed_func( + LIB.xfixes, "xcb_xfixes_get_cursor_image", [POINTER(Connection)], XfixesGetCursorImageReply + ) diff --git a/src/mss/linux/xcbhelpers.py b/src/mss/linux/xcbhelpers.py new file mode 100644 index 00000000..f387c79e --- /dev/null +++ b/src/mss/linux/xcbhelpers.py @@ -0,0 +1,588 @@ +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}{ext_desc}" + + (f"\n Minor opcode of failed request: {minor_desc}" if details["minor_code"] != 0 else "") + + f"\n Resource id in failed request: {details['resource_id']}" + f"\n Serial number of failed request: {details['full_sequence']}" + ) + return msg + + +class CookieBase(Structure): + """Generic XCB cookie. + + XCB does not export this as a base type. However, all XCB cookies + have the same structure, so this encompasses the common structure + in Python. + """ + + # It's possible to add a finalizer that will raise an exception if a cookie is garbage collected without being + # disposed of (through discard, check, or reply). If we ever start using asynchronous requests, then that would + # be good to add. But for now, we can trust the wrapper functions to manage the cookies correctly, without the + # extra overhead of these finalizers. + + _fields_ = (("sequence", c_uint),) + + def discard(self, xcb_conn: Connection) -> None: + """Free memory associated with this request, and ignore errors.""" + LIB.xcb.xcb_discard_reply(xcb_conn, self.sequence) + + +class VoidCookie(CookieBase): + """XCB cookie for requests with no responses. + + This corresponds to xcb_void_cookie_t. + """ + + def check(self, xcb_conn: Connection) -> None: + """Verify that the function completed successfully. + + This will raise an exception if there is an error. + """ + err_p = LIB.xcb.xcb_request_check(xcb_conn, self) + if not err_p: + return + err = copy(err_p.contents) + LIB.c.free(err_p) + raise XProtoError(xcb_conn, err) + + +class ReplyCookieBase(CookieBase): + _xcb_reply_func = None + + def reply(self, xcb_conn: Connection) -> Structure: + """Wait for and return the server's response. + + The response will be freed (with libc's free) when it, and its + descendents, are no longer referenced. + + If the server indicates an error, an exception is raised + instead. + """ + err_p = POINTER(GenericErrorStructure)() + assert self._xcb_reply_func is not None # noqa: S101 + reply_p = self._xcb_reply_func(xcb_conn, self, err_p) + if err_p: + # I think this is always NULL, but we can free it. + if reply_p: + LIB.c.free(reply_p) + # Copying the error structure is cheap, and makes memory management easier. + err_copy = copy(err_p.contents) + LIB.c.free(err_p) + raise XProtoError(xcb_conn, err_copy) + assert reply_p # noqa: S101 + + # It's not known, at this point, how long the reply structure actually is: there may be trailing data that + # needs to be processed and then freed. We have to set a finalizer on the reply, so it can be freed when + # Python is done with it. The whole dependency tree, though, leads back to this object and its finalizer. + # Importantly, reply_void_p does not carry a reference (direct or indirect) to reply_p; that would prevent + # it from ever being freed. + reply_void_p = c_void_p(addressof(reply_p.contents)) + finalizer = finalize(reply_p, LIB.c.free, reply_void_p) + finalizer.atexit = False + return reply_p.contents + + +def initialize_xcb_typed_func(lib: CDLL, name: str, request_argtypes: list, reply_struct: type) -> None: + """Set up ctypes for a response-returning XCB function. + + This is only applicable to checked (the default) variants of + functions that have a response type. + + This arranges for the ctypes function to take the given argtypes. + The ctypes function will then return an XcbTypedCookie (rather, + a function-specific subclass of it). That can be used to call the + XCB xcb_blahblah_reply function to check for errors and return the + server's response. + """ + + base_name = name + title_name = base_name.title().replace("_", "") + request_func = getattr(lib, name) + reply_func = getattr(lib, f"{name}_reply") + # The cookie type isn't used outside this function, so we can just declare it here implicitly. + cookie_type = type(f"{title_name}Cookie", (ReplyCookieBase,), {"_xcb_reply_func": reply_func}) + request_func.argtypes = request_argtypes + request_func.restype = cookie_type + reply_func.argtypes = [POINTER(Connection), cookie_type, POINTER(POINTER(GenericErrorStructure))] + reply_func.restype = POINTER(reply_struct) + + +### XCB types + + +class XcbExtension(Structure): + _fields_ = (("name", c_char_p), ("global_id", c_int)) + + +class XcbErrorsContext(Structure): + """A context for using libxcb-errors. + + Create a context with xcb_errors_context_new() and destroy it with + xcb_errors_context_free(). Except for xcb_errors_context_free(), + all functions in libxcb-errors are thread-safe and can be called + from multiple threads at the same time, even on the same context. + """ + + +#### Types for special-cased functions + + +class QueryExtensionReply(Structure): + _fields_ = ( + ("response_type", c_uint8), + ("pad0", c_uint8), + ("sequence", c_uint16), + ("length", c_uint32), + ("present", c_uint8), + ("major_opcode", c_uint8), + ("first_event", c_uint8), + ("first_error", c_uint8), + ) + + +#### XCB libraries singleton + + +class LibContainer: + """Container for XCB-related libraries. + + There is one instance exposed as the xcb.LIB global. + + You can access libxcb.so as xcb.LIB.xcb, libc as xcb.LIB.c, etc. + These are not set up until initialize() is called. It is safe to + call initialize() multiple times. + + Library accesses through this container return the ctypes CDLL + object. There are no smart wrappers (although the return types are + the cookie classes defined above). In other words, if you're + accessing xcb.LIB.xcb.xcb_foo, then you need to handle the .reply() + calls and such yourself. If you're accessing the wrapper functions + in the xcb module xcb.foo, then it will take care of that for you. + """ + + _EXPOSED_NAMES = frozenset( + {"c", "xcb", "randr", "randr_id", "render", "render_id", "xfixes", "xfixes_id", "errors"} + ) + + def __init__(self) -> None: + self._lock = RLock() + self._initializing = False + self.initialized = False + + def reset(self) -> None: + with self._lock: + if self._initializing: + msg = "Cannot reset during initialization" + raise RuntimeError(msg) + self.initialized = False + for name in self._EXPOSED_NAMES: + with suppress(AttributeError): + delattr(self, name) + + def initialize(self, callbacks: Iterable[Callable[[], None]] = frozenset()) -> None: # noqa: PLR0915 + # We'll need a couple of generated types, but we have to load them late, since xcbgen requires this library. + from .xcbgen import Setup # noqa: PLC0415 + + with self._lock: + if self.initialized: + # Something else initialized this object while we were waiting for the lock. + return + + if self._initializing: + msg = "Cannot load during initialization" + raise RuntimeError(msg) + + try: + self._initializing = True + + # We don't use the cached versions that ctypes.cdll exposes as attributes, since other libraries may be + # doing their own things with these. + + # We use the libc that the current process has loaded, to make sure we get the right version of free(). + # ctypes doesn't document that None is valid as the argument to LoadLibrary, but it does the same thing + # as a NULL argument to dlopen: it returns the current process and its loaded libraries. This includes + # libc. + self.c = cdll.LoadLibrary(None) # type: ignore[arg-type] + self.c.free.argtypes = [c_void_p] + self.c.free.restype = None + + libxcb_so = ctypes.util.find_library("xcb") + if libxcb_so is None: + msg = "Library libxcb.so not found" + raise ScreenShotError(msg) + self.xcb = cdll.LoadLibrary(libxcb_so) + + self.xcb.xcb_request_check.argtypes = [POINTER(Connection), VoidCookie] + self.xcb.xcb_request_check.restype = POINTER(GenericErrorStructure) + self.xcb.xcb_discard_reply.argtypes = [POINTER(Connection), c_uint] + self.xcb.xcb_discard_reply.restype = None + self.xcb.xcb_get_extension_data.argtypes = [POINTER(Connection), POINTER(XcbExtension)] + self.xcb.xcb_get_extension_data.restype = POINTER(QueryExtensionReply) + self.xcb.xcb_prefetch_extension_data.argtypes = [POINTER(Connection), POINTER(XcbExtension)] + self.xcb.xcb_prefetch_extension_data.restype = None + self.xcb.xcb_generate_id.argtypes = [POINTER(Connection)] + self.xcb.xcb_generate_id.restype = XID + self.xcb.xcb_get_setup.argtypes = [POINTER(Connection)] + self.xcb.xcb_get_setup.restype = POINTER(Setup) + self.xcb.xcb_connection_has_error.argtypes = [POINTER(Connection)] + self.xcb.xcb_connection_has_error.restype = c_int + self.xcb.xcb_connect.argtypes = [c_char_p, POINTER(c_int)] + self.xcb.xcb_connect.restype = POINTER(Connection) + self.xcb.xcb_disconnect.argtypes = [POINTER(Connection)] + self.xcb.xcb_disconnect.restype = None + + libxcb_randr_so = ctypes.util.find_library("xcb-randr") + if libxcb_randr_so is None: + msg = "Library libxcb-randr.so not found" + raise ScreenShotError(msg) + self.randr = cdll.LoadLibrary(libxcb_randr_so) + self.randr_id = XcbExtension.in_dll(self.randr, "xcb_randr_id") + + libxcb_render_so = ctypes.util.find_library("xcb-render") + if libxcb_render_so is None: + msg = "Library libxcb-render.so not found" + raise ScreenShotError(msg) + self.render = cdll.LoadLibrary(libxcb_render_so) + self.render_id = XcbExtension.in_dll(self.render, "xcb_render_id") + + libxcb_shm_so = ctypes.util.find_library("xcb-shm") + if libxcb_shm_so is None: + msg = "Library libxcb-shm.so not found" + raise ScreenShotError(msg) + self.shm = cdll.LoadLibrary(libxcb_shm_so) + self.shm_id = XcbExtension.in_dll(self.shm, "xcb_shm_id") + + libxcb_xfixes_so = ctypes.util.find_library("xcb-xfixes") + if libxcb_xfixes_so is None: + msg = "Library libxcb-xfixes.so not found" + raise ScreenShotError(msg) + self.xfixes = cdll.LoadLibrary(libxcb_xfixes_so) + self.xfixes_id = XcbExtension.in_dll(self.xfixes, "xcb_xfixes_id") + + # xcb_errors is an optional library, mostly only useful to developers. We use the qualified .so name, + # since it's subject to change incompatibly. + try: + self.errors: CDLL | None = cdll.LoadLibrary("libxcb-errors.so.0") + except Exception: # noqa: BLE001 + self.errors = None + else: + self.errors.xcb_errors_context_new.argtypes = [ + POINTER(Connection), + POINTER(POINTER(XcbErrorsContext)), + ] + self.errors.xcb_errors_context_new.restype = c_int + self.errors.xcb_errors_context_free.argtypes = [POINTER(XcbErrorsContext)] + self.errors.xcb_errors_context_free.restype = None + self.errors.xcb_errors_get_name_for_major_code.argtypes = [POINTER(XcbErrorsContext), c_uint8] + self.errors.xcb_errors_get_name_for_major_code.restype = c_char_p + self.errors.xcb_errors_get_name_for_minor_code.argtypes = [ + POINTER(XcbErrorsContext), + c_uint8, + c_uint16, + ] + self.errors.xcb_errors_get_name_for_minor_code.restype = c_char_p + self.errors.xcb_errors_get_name_for_error.argtypes = [ + POINTER(XcbErrorsContext), + c_uint8, + POINTER(c_char_p), + ] + self.errors.xcb_errors_get_name_for_error.restype = c_char_p + + for x in callbacks: + x() + + finally: + self._initializing = False + + self.initialized = True + + +LIB = LibContainer() + + +#### Trailing data accessors +# +# In X11, many replies have the header (the *Reply structures defined above), plus some variable-length data after it. +# For instance, XcbScreen includes a list of XcbDepth structures. +# +# These mostly follow two patterns. +# +# For objects with a constant size, we get a pointer and length (count), cast to an array, and return the array +# contents. (This doesn't involve copying any data.) +# +# For objects with a variable size, we use the XCB-provided iterator protocol to iterate over them, and return a +# Python list. (This also doesn't copy any data, but does construct a list.) To continue the example of how +# XcbScreen includes a list of XcbDepth structures: a full XcbDepth is variable-length because it has a variable +# number of visuals attached to it. +# +# These lists with variable element sizes follow a standard pattern: +# +# * There is an iterator class (such as XcbScreenIterator), based on the type you're iterating over. This defines a +# data pointer to point to the current object, and a rem counter indicating the remaining number of objects. +# * There is a function to advance the iterator (such as xcb_screen_next), based on the type of iterator being +# advanced. +# * There is an initializer function (such as xcb_setup_roots_iterator) that takes the container (XcbSetup), and +# returns an iterator (XcbScreenIterator) pointing to the first object in the list. (This iterator is returned by +# value, so Python can free it normally.) +# +# The returned structures are actually part of the allocation of the parent pointer: the POINTER(XcbScreen) objects +# point to objects that were allocated along with the XcbSetup that we got them from. That means that it is very +# important that the XcbSetup not be freed until the pointers that point into it are freed. + + +### Iteration utility primitives + + +def list_from_xcb(iterator_factory: Callable, next_func: Callable, parent: Structure | _Pointer) -> list: + iterator = iterator_factory(parent) + items: list = [] + while iterator.rem != 0: + current = iterator.data.contents + # Keep the parent reply alive until consumers drop this entry. + depends_on(current, parent) + items.append(current) + next_func(iterator) + return items + + +def array_from_xcb(pointer_func: Callable, length_func: Callable, parent: Structure | _Pointer) -> Array: + pointer = pointer_func(parent) + length = length_func(parent) + if length and not pointer: + msg = "XCB returned a NULL pointer for non-zero data length" + raise ScreenShotError(msg) + array_ptr = cast(pointer, POINTER(pointer._type_ * length)) + array = array_ptr.contents + depends_on(array, parent) + return array diff --git a/src/mss/linux/xgetimage.py b/src/mss/linux/xgetimage.py new file mode 100644 index 00000000..a41368c6 --- /dev/null +++ b/src/mss/linux/xgetimage.py @@ -0,0 +1,18 @@ +from mss.models import Monitor +from mss.screenshot import ScreenShot + +from .base import MSSXCBBase + + +class MSS(MSSXCBBase): + """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 _grab_impl(self, monitor: Monitor) -> ScreenShot: + """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" + return super()._grab_impl_xgetimage(monitor) 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/linux/xshmgetimage.py b/src/mss/linux/xshmgetimage.py new file mode 100644 index 00000000..981f2116 --- /dev/null +++ b/src/mss/linux/xshmgetimage.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import enum +import os +from mmap import PROT_READ, mmap # type: ignore[attr-defined] +from typing import TYPE_CHECKING, Any + +from mss.exception import ScreenShotError +from mss.linux import xcb +from mss.linux.xcbhelpers import LIB, XProtoError + +from .base import ALL_PLANES, MSSXCBBase + +if TYPE_CHECKING: + from mss.models import Monitor + from mss.screenshot import ScreenShot + + +class ShmStatus(enum.Enum): + UNKNOWN = enum.auto() # Constructor says SHM *should* work, but we haven't seen a real GetImage succeed yet. + AVAILABLE = enum.auto() # We've successfully used XShmGetImage at least once. + UNAVAILABLE = enum.auto() # We know SHM GetImage is unusable; always use XGetImage. + + +class MSS(MSSXCBBase): + """Multiple ScreenShots implementation for GNU/Linux. + + This implementation is based on XCB, using the ShmGetImage request. + If ShmGetImage fails, then this will fall back to using GetImage. + In that event, the reason for the fallback will be recorded in the + shm_fallback_reason attribute as a string, for debugging purposes. + """ + + def __init__(self, /, **kwargs: Any) -> None: + super().__init__(**kwargs) + + # These are the objects we need to clean up when we shut down. They are created in _setup_shm. + self._memfd: int | None = None + self._buf: mmap | None = None + self._shmseg: xcb.ShmSeg | None = None + + # Rather than trying to track the shm_status, we may be able to raise an exception in __init__ if XShmGetImage + # isn't available. The factory in linux/__init__.py could then catch that and switch to XGetImage. + # The conditions under which the attach will succeed but the xcb_shm_get_image will fail are extremely + # rare, and I haven't yet found any that also will work with xcb_get_image. + self.shm_status: ShmStatus = self._setup_shm() + self.shm_fallback_reason: str | None = None + + def _shm_report_issue(self, msg: str, *args: Any) -> None: + """Debugging hook for troubleshooting MIT-SHM issues. + + This will be called whenever MIT-SHM is disabled. The optional + arguments are not well-defined; exceptions are common. + """ + full_msg = msg + if args: + full_msg += " | " + ", ".join(str(arg) for arg in args) + self.shm_fallback_reason = full_msg + + def _setup_shm(self) -> ShmStatus: # noqa: PLR0911 + assert self.conn is not None # noqa: S101 + + try: + shm_ext_data = xcb.get_extension_data(self.conn, LIB.shm_id) + if not shm_ext_data.present: + self._shm_report_issue("MIT-SHM extension not present") + return ShmStatus.UNAVAILABLE + + # We use the FD-based version of ShmGetImage, so we require the extension to be at least 1.2. + shm_version_data = xcb.shm_query_version(self.conn) + shm_version = (shm_version_data.major_version, shm_version_data.minor_version) + if shm_version < (1, 2): + self._shm_report_issue("MIT-SHM version too old", shm_version) + return ShmStatus.UNAVAILABLE + + # We allocate something large enough for the root, so we don't have to reallocate each time the window is + # resized. + self._bufsize = self.pref_screen.width_in_pixels * self.pref_screen.height_in_pixels * 4 + + if not hasattr(os, "memfd_create"): + self._shm_report_issue("os.memfd_create not available") + return ShmStatus.UNAVAILABLE + try: + self._memfd = os.memfd_create("mss-shm-buf", flags=os.MFD_CLOEXEC) # type: ignore[attr-defined] + except OSError as e: + self._shm_report_issue("memfd_create failed", e) + self._shutdown_shm() + return ShmStatus.UNAVAILABLE + os.ftruncate(self._memfd, self._bufsize) + + try: + self._buf = mmap(self._memfd, self._bufsize, prot=PROT_READ) # type: ignore[call-arg] + except OSError as e: + self._shm_report_issue("mmap failed", e) + self._shutdown_shm() + return ShmStatus.UNAVAILABLE + + self._shmseg = xcb.ShmSeg(xcb.generate_id(self.conn).value) + try: + # This will normally be what raises an exception if you're on a remote connection. + # XCB will close _memfd, on success or on failure. + try: + xcb.shm_attach_fd(self.conn, self._shmseg, self._memfd, read_only=False) + finally: + self._memfd = None + except xcb.XError as e: + self._shm_report_issue("Cannot attach MIT-SHM segment", e) + self._shutdown_shm() + return ShmStatus.UNAVAILABLE + + except Exception: + self._shutdown_shm() + raise + + return ShmStatus.UNKNOWN + + def close(self) -> None: + self._shutdown_shm() + super().close() + + def _shutdown_shm(self) -> None: + # It would be nice to also try to tell the server to detach the shmseg, but we might be in an error path + # and don't know if that's possible. It's not like we'll leak a lot of them on the same connection anyway. + # This can be called in the path of partial initialization. + if self._buf is not None: + self._buf.close() + self._buf = None + if self._memfd is not None: + os.close(self._memfd) + self._memfd = None + + def _grab_impl_xshmgetimage(self, monitor: Monitor) -> ScreenShot: + if self.conn is None: + msg = "Cannot take screenshot while the connection is closed" + raise ScreenShotError(msg) + assert self._buf is not None # noqa: S101 + assert self._shmseg is not None # noqa: S101 + + required_size = monitor["width"] * monitor["height"] * 4 + if required_size > self._bufsize: + # This is temporary. The permanent fix will depend on how + # issue https://github.com/BoboTiG/python-mss/issues/432 is resolved. + msg = ( + "Requested capture size exceeds the allocated buffer. If you have resized the screen, " + "please recreate your MSS object." + ) + raise ScreenShotError(msg) + + img_reply = xcb.shm_get_image( + self.conn, + self.drawable.value, + monitor["left"], + monitor["top"], + monitor["width"], + monitor["height"], + ALL_PLANES, + xcb.ImageFormat.ZPixmap, + self._shmseg, + 0, + ) + + if img_reply.depth != self.drawable_depth or img_reply.visual.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) + + # Snapshot the buffer into new bytearray. + new_size = monitor["width"] * monitor["height"] * 4 + # Slicing the memoryview creates a new memoryview that points to the relevant subregion. Making this and + # then copying it into a fresh bytearray is much faster than slicing the mmap object. + img_mv = memoryview(self._buf)[:new_size] + img_data = bytearray(img_mv) + + return self.cls_image(img_data, monitor) + + def _grab_impl(self, monitor: Monitor) -> ScreenShot: + """Retrieve all pixels from a monitor. Pixels have to be RGBX.""" + if self.shm_status == ShmStatus.UNAVAILABLE: + return super()._grab_impl_xgetimage(monitor) + + # The usual path is just the next few lines. + try: + rv = self._grab_impl_xshmgetimage(monitor) + self.shm_status = ShmStatus.AVAILABLE + except XProtoError as e: + if self.shm_status != ShmStatus.UNKNOWN: + # We know XShmGetImage works, because it worked earlier. Reraise the error. + raise + + # Should we engage the fallback path? In almost all cases, if XShmGetImage failed at this stage (after + # all our testing in __init__), XGetImage will also fail. This could mean that the user sent an + # out-of-bounds request. In more exotic situations, some rare X servers disallow screen capture + # altogether: security-hardened servers, for instance, or some XPrint servers. But let's make sure, by + # testing the same request through XGetImage. + try: + rv = super()._grab_impl_xgetimage(monitor) + except XProtoError: # noqa: TRY203 + # The XGetImage also failed, so we don't know anything about whether XShmGetImage is usable. Maybe + # the user sent an out-of-bounds request. Maybe it's a security-hardened server. We're not sure what + # the problem is. So, if XGetImage failed, we re-raise that error (the one from XShmGetImage will be + # attached as __context__), but we won't update the shm_status yet. (Technically, our except:raise + # clause here is redundant; it's just for clarity, to hold this comment.) + raise + + # Using XShmGetImage failed, and using XGetImage worked. Use XGetImage in the future. + self._shm_report_issue("MIT-SHM GetImage failed", e) + self.shm_status = ShmStatus.UNAVAILABLE + self._shutdown_shm() + + return rv diff --git a/src/mss/models.py b/src/mss/models.py 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/src/mss/tools.py b/src/mss/tools.py new file mode 100644 index 00000000..9eb8b6f7 --- /dev/null +++ b/src/mss/tools.py @@ -0,0 +1,65 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +import os +import struct +import zlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + +def to_png(data: bytes, size: tuple[int, int], /, *, level: int = 6, output: Path | str | None = None) -> bytes | None: + """Dump data to a PNG file. If `output` is `None`, create no file but return + the whole PNG data. + + :param bytes data: RGBRGB...RGB data. + :param tuple size: The (width, height) pair. + :param int level: PNG compression level. + :param str output: Output file name. + """ + 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)]) + + magic = pack(">8B", 137, 80, 78, 71, 13, 10, 26, 10) + + # Header: size, marker, data, CRC32 + ihdr = [b"", b"IHDR", b"", b""] + ihdr[2] = pack(">2I5B", width, height, 8, 2, 0, 0, 0) + ihdr[3] = pack(">I", crc32(b"".join(ihdr[1:3])) & 0xFFFFFFFF) + ihdr[0] = pack(">I", len(ihdr[2])) + + # Data: size, marker, data, CRC32 + idat = [b"", b"IDAT", zlib.compress(scanlines, level), b""] + idat[3] = pack(">I", crc32(b"".join(idat[1:3])) & 0xFFFFFFFF) + idat[0] = pack(">I", len(idat[2])) + + # Footer: size, marker, None, CRC32 + iend = [b"", b"IEND", b"", b""] + iend[3] = pack(">I", crc32(iend[1]) & 0xFFFFFFFF) + iend[0] = pack(">I", len(iend[2])) + + if not output: + # Returns raw bytes of the whole PNG data + return magic + b"".join(ihdr + idat + iend) + + with open(output, "wb") as fileh: # noqa: PTH123 + fileh.write(magic) + fileh.write(b"".join(ihdr)) + fileh.write(b"".join(idat)) + fileh.write(b"".join(iend)) + + # Force write of file to disk + fileh.flush() + os.fsync(fileh.fileno()) + + return None diff --git a/src/mss/windows.py b/src/mss/windows.py new file mode 100644 index 00000000..b84daa3e --- /dev/null +++ b/src/mss/windows.py @@ -0,0 +1,252 @@ +"""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",) + +BACKENDS = ["default"] + + +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/tests/bench_bgra2rgb.py b/src/tests/bench_bgra2rgb.py similarity index 64% rename from tests/bench_bgra2rgb.py rename to src/tests/bench_bgra2rgb.py index 2560f900..6acaffb3 100644 --- a/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/tests/bench_general.py b/src/tests/bench_general.py similarity index 68% rename from tests/bench_general.py rename to src/tests/bench_general.py index dab3fb27..100a4729 100644 --- a/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..08b98548 --- /dev/null +++ b/src/tests/conftest.py @@ -0,0 +1,108 @@ +"""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", "xshmgetimage"] 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) + + +@pytest.fixture(autouse=True, scope="session") +def inhibit_x11_resets() -> Generator[None, None, None]: + """Ensure that an X11 connection is open during the test session. + + Under X11, when the last client disconnects, the server resets. If + a new client tries to connect before the reset is complete, it may fail. + Since we often run the tests under Xvfb, they're frequently the only + clients. Since our tests run in rapid succession, this combination + can lead to intermittent failures. + + To avoid this, we open a connection at the start of the test session + and keep it open until the end. + """ + if system() != "Linux": + yield + return + + conn, _ = xcb.connect() + try: + yield + finally: + # Some tests may have reset xcb.LIB, so make sure it's currently initialized. + xcb.initialize() + xcb.disconnect(conn) diff --git a/src/tests/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/tests/test_bgra_to_rgb.py b/src/tests/test_bgra_to_rgb.py similarity index 55% rename from tests/test_bgra_to_rgb.py rename to src/tests/test_bgra_to_rgb.py index ee64ed70..a481c1f1 100644 --- a/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..6bb118ac --- /dev/null +++ b/src/tests/test_gnu_linux.py @@ -0,0 +1,350 @@ +"""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 in {"xgetimage", "xshmgetimage"} 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]) + + +def test_shm_available() -> None: + """Verify that the xshmgetimage backend doesn't always fallback. + + Since this backend does an automatic fallback for certain types of + anticipated issues, that could cause some failures to be masked. + Ensure this isn't happening. + """ + with ( + pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH) as vdisplay, + mss.mss(display=vdisplay.new_display_var, backend="xshmgetimage") as sct, + ): + assert isinstance(sct, mss.linux.xshmgetimage.MSS) # For Mypy + # The status currently isn't established as final until a grab succeeds. + sct.grab(sct.monitors[0]) + assert sct.shm_status == mss.linux.xshmgetimage.ShmStatus.AVAILABLE + + +def test_shm_fallback() -> None: + """Verify that the xshmgetimage backend falls back if MIT-SHM fails. + + The most common case when a fallback is needed is with a TCP + connection, such as the one used with ssh relaying. By using + DISPLAY=localhost:99 instead of DISPLAY=:99, we connect over TCP + instead of a local-domain socket. This is sufficient to prevent + MIT-SHM from completing its setup: the extension is available, but + won't be able to attach a shared memory segment. + """ + with ( + pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH, extra_args=["-listen", "tcp"]) as vdisplay, + mss.mss(display=f"localhost{vdisplay.new_display_var}", backend="xshmgetimage") as sct, + ): + assert isinstance(sct, mss.linux.xshmgetimage.MSS) # For Mypy + # Ensure that the grab call completes without exception. + sct.grab(sct.monitors[0]) + # Ensure that it really did have to fall back; otherwise, we'd need to change how we test this case. + assert sct.shm_status == mss.linux.xshmgetimage.ShmStatus.UNAVAILABLE diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py new file mode 100644 index 00000000..9ee55adb --- /dev/null +++ b/src/tests/test_implementation.py @@ -0,0 +1,300 @@ +"""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." + + +@pytest.fixture +def reset_sys_argv(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "argv", []) + + +@pytest.mark.usefixtures("reset_sys_argv") +@pytest.mark.parametrize("with_cursor", [False, True]) +class TestEntryPoint: + """CLI entry-point scenarios split into focused tests.""" + + @staticmethod + def _run_main(with_cursor: bool, *args: str, ret: int = 0) -> None: + if with_cursor: + args = (*args, "--with-cursor") + assert entry_point(*args) == ret + + def test_no_arguments(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + self._run_main(with_cursor) + 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() + + def test_monitor_option_and_quiet(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + file = Path("monitor-1.png") + filename: Path | None = None + for opt in ("-m", "--monitor"): + self._run_main(with_cursor, opt, "1") + captured = capsys.readouterr() + assert captured.out.endswith(f"{file.name}\n") + filename = Path(captured.out.rstrip()) + assert filename.is_file() + filename.unlink() + + assert filename is not None + for opts in zip(["-m 1", "--monitor=1"], ["-q", "--quiet"]): + self._run_main(with_cursor, *opts) + captured = capsys.readouterr() + assert not captured.out + assert filename.is_file() + filename.unlink() + + def test_custom_output_pattern(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + fmt = "sct-{mon}-{width}x{height}.png" + for opt in ("-o", "--out"): + self._run_main(with_cursor, opt, fmt) + captured = capsys.readouterr() + with mss.mss(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() + + def test_output_pattern_with_date(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + fmt = "sct_{mon}-{date:%Y-%m-%d}.png" + for opt in ("-o", "--out"): + self._run_main(with_cursor, "-m 1", opt, fmt) + filename = Path(fmt.format(mon=1, date=datetime.now(tz=UTC))) + captured = capsys.readouterr() + assert captured.out.endswith(f"{filename}\n") + assert filename.is_file() + filename.unlink() + + def test_coordinates_capture(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + coordinates = "2,12,40,67" + filename = Path("sct-2x12_40x67.png") + for opt in ("-c", "--coordinates"): + self._run_main(with_cursor, opt, coordinates) + captured = capsys.readouterr() + assert captured.out.endswith(f"{filename}\n") + assert filename.is_file() + filename.unlink() + + def test_invalid_coordinates(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + coordinates = "2,12,40" + for opt in ("-c", "--coordinates"): + self._run_main(with_cursor, opt, coordinates, ret=2) + captured = capsys.readouterr() + assert captured.out == "Coordinates syntax: top, left, width, height\n" + + def test_backend_option(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + backend = "default" + for opt in ("-b", "--backend"): + self._run_main(with_cursor, opt, backend, "-m1") + captured = capsys.readouterr() + filename = Path(captured.out.rstrip()) + assert filename.is_file() + filename.unlink() + + def test_invalid_backend_option(self, with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + backend = "chuck_norris" + for opt in ("-b", "--backend"): + self._run_main(with_cursor, opt, backend, "-m1", ret=2) + captured = capsys.readouterr() + assert "argument -b/--backend: invalid choice: 'chuck_norris' (choose from" in captured.err + + +@patch.object(sys, "argv", new=[]) # Prevent side effects while testing +@patch("mss.base.MSSBase.monitors", new=[]) +@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..295dba23 --- /dev/null +++ b/src/tests/test_setup.py @@ -0,0 +1,153 @@ +"""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/linux_xshm_backend.py", + f"mss-{__version__}/docs/source/examples/opencv_numpy.py", + f"mss-{__version__}/docs/source/examples/part_of_screen.py", + f"mss-{__version__}/docs/source/examples/part_of_screen_monitor_2.py", + 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/base.py", + f"mss-{__version__}/src/mss/linux/xcb.py", + f"mss-{__version__}/src/mss/linux/xcbgen.py", + f"mss-{__version__}/src/mss/linux/xcbhelpers.py", + f"mss-{__version__}/src/mss/linux/xgetimage.py", + f"mss-{__version__}/src/mss/linux/xlib.py", + f"mss-{__version__}/src/mss/linux/xshmgetimage.py", + f"mss-{__version__}/src/mss/models.py", + f"mss-{__version__}/src/mss/py.typed", + f"mss-{__version__}/src/mss/screenshot.py", + f"mss-{__version__}/src/mss/tools.py", + f"mss-{__version__}/src/mss/windows.py", + f"mss-{__version__}/src/tests/__init__.py", + f"mss-{__version__}/src/tests/bench_bgra2rgb.py", + f"mss-{__version__}/src/tests/bench_general.py", + f"mss-{__version__}/src/tests/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/shm.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/base.py", + "mss/linux/xcb.py", + "mss/linux/xcbgen.py", + "mss/linux/xcbhelpers.py", + "mss/linux/xgetimage.py", + "mss/linux/xlib.py", + "mss/linux/xshmgetimage.py", + "mss/models.py", + "mss/py.typed", + "mss/screenshot.py", + "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..24903ace --- /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 base, 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(xcb, "LIB", fake_lib) + self._monkeypatch.setattr(xcb, "connect", lambda _display=None: (self.connection, 0)) + self._monkeypatch.setattr(xcb, "disconnect", lambda _conn: None) + self._monkeypatch.setattr(xcb, "setup_roots", self._setup_roots) + self._monkeypatch.setattr(xcb, "setup_pixmap_formats", self._setup_pixmap_formats) + self._monkeypatch.setattr(xcb, "screen_allowed_depths", self._screen_allowed_depths) + self._monkeypatch.setattr(xcb, "depth_visuals", self._depth_visuals) + + self.reset() + + def reset(self) -> None: + self.setup.image_byte_order = xcb.ImageOrder.LSBFirst + self.screen.root = xcb.Window(1) + self.screen.root_depth = 32 + visual_id = 0x1234 + self.screen.root_visual = xcb.Visualid(visual_id) + + self.format.depth = self.screen.root_depth + self.format.bits_per_pixel = base.SUPPORTED_BITS_PER_PIXEL + self.format.scanline_pad = base.SUPPORTED_BITS_PER_PIXEL + + self.depth.depth = self.screen.root_depth + + self.visual.visual_id = xcb.Visualid(visual_id) + self.visual.class_ = xcb.VisualClass.TrueColor + self.visual.red_mask = base.SUPPORTED_RED_MASK + self.visual.green_mask = base.SUPPORTED_GREEN_MASK + self.visual.blue_mask = base.SUPPORTED_BLUE_MASK + + self.screens = [self.screen] + self.pixmap_formats = [self.format] + self.depths = [self.depth] + self.visuals = [self.visual] + + def _setup_roots(self, _setup: xcb.Setup) -> list[xcb.Screen]: + return self.screens + + def _setup_pixmap_formats(self, _setup: xcb.Setup) -> list[xcb.Format]: + return self.pixmap_formats + + def _screen_allowed_depths(self, _screen: xcb.Screen) -> list[xcb.Depth]: + return self.depths + + def _depth_visuals(self, _depth: xcb.Depth) -> list[xcb.Visualtype]: + return self.visuals + + +@pytest.fixture +def visual_validation_env(monkeypatch: pytest.MonkeyPatch) -> _VisualValidationHarness: + return _VisualValidationHarness(monkeypatch) + + +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..351e0b97 --- /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`. +5. 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..1e41acc8 --- /dev/null +++ b/src/xcbproto/gen_xcb_to_py.py @@ -0,0 +1,1300 @@ +#!/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", + ], + "shm": [ + "QueryVersion", + "GetImage", + "AttachFd", + "CreateSegment", + "Detach", + ], + "xfixes": [ + "QueryVersion", + "GetCursorImage", + ], +} + +# ---------------------------------------------------------------------------- +# Constant data used by the generator + +PRIMITIVE_CTYPES: dict[str, str] = { + "CARD8": "c_uint8", + "CARD16": "c_uint16", + "CARD32": "c_uint32", + "CARD64": "c_uint64", + "INT8": "c_int8", + "INT16": "c_int16", + "INT32": "c_int32", + "INT64": "c_int64", + "BYTE": "c_uint8", + "BOOL": "c_uint8", + "char": "c_char", + "void": "None", +} + +INT_CTYPES = {"c_int", "c_int8", "c_int16", "c_int32", "c_int64", "c_uint8", "c_uint16", "c_uint32", "c_uint64"} + +EIGHT_BIT_TYPES = { + "c_int8", + "c_uint8", + "c_char", +} + +XCB_LENGTH_TYPE = "c_int" + +RESERVED_NAMES = set(keyword.kwlist) | set(keyword.softkwlist) | set(dir(builtins)) + +GENERATED_HEADER = """# Auto-generated by gen_xcb_to_py.py - do not edit manually. + +# Since many of the generated functions have many parameters, we disable the pylint warning about too many arguments. +# ruff: noqa: PLR0913 + +from __future__ import annotations + +from ctypes import ( + POINTER, + Array, + Structure, + Union, + _Pointer, + c_char, + c_char_p, + c_double, + c_float, + c_int, + c_int8, + c_int16, + c_int32, + c_int64, + c_uint, + c_uint8, + c_uint16, + c_uint32, + c_uint64, + c_void_p, +) +from enum import IntEnum + +from mss.linux.xcbhelpers import ( + LIB, + XID, + Connection, + VoidCookie, + array_from_xcb, + initialize_xcb_typed_func, + list_from_xcb, +) +""" + + +# ---------------------------------------------------------------------------- +# Utility helpers. + + +class GenerationError(RuntimeError): + """Raised when the XML describes a construct this generator cannot handle yet.""" + + def __init__(self, message: str, *, element: ET._Element | None = None) -> None: + super().__init__(message) + self._element = element + + def __str__(self) -> str: + base = super().__str__() + if self._element is None: + return base + + element_base = getattr(self._element, "base", None) + element_line = getattr(self._element, "sourceline", None) + if element_base and element_line: + return f"{element_base}:{element_line}: {base}" + return base + + +@contextmanager +def parsing_note(description: str, element: ET._Element | None = None) -> Generator[None]: + """Context manager to add parsing context to exceptions. + + Use when parsing XML elements to provide better error messages. + + Example: + with parsing_note("while parsing struct Foo", element): + ... + """ + try: + yield + except Exception as exc: + note = description + if element is not None: + base = getattr(element, "base", None) + line = getattr(element, "sourceline", None) + if base and line: + note = f"{description} at {base}:{line}" + exc.add_note(note) + raise + + +class LazyDefn: + """Base class for lazily parsed protocol definitions. + + We lazily parse certain definitions so that we only need to support parsing + the features that are actually used by the requested types and requests, + rather than the entire XCB spec. + """ + + def __init__(self, *, protocol: str, name: str, element: ET._Element) -> None: + self.protocol = protocol + self.name = name + self._element = element + self._parsed = False + self._parsing = False + + def _parse(self) -> None: + raise NotImplementedError + + def _ensure_parsed(self) -> None: + if self._parsed: + return + if self._parsing: + msg = f"Re-entrant parse detected for {self!r}" + raise RuntimeError(msg) + self._parsing = True + try: + self._parse() + finally: + self._parsing = False + self._parsed = True + + def parse(self) -> None: + self._ensure_parsed() + + def __getattr__(self, name: str) -> Any: + self._ensure_parsed() + if name in self.__dict__: + return self.__dict__[name] + msg = f"{type(self).__name__!r} object has no attribute {name!r}" + raise AttributeError(msg) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.protocol!r}, {self.name!r})" + + +def resolve_primitive(name: str) -> str | None: + upper = name.upper() + if upper in PRIMITIVE_CTYPES: + return PRIMITIVE_CTYPES[upper] + return PRIMITIVE_CTYPES.get(name) + + +# ---------------------------------------------------------------------------- +# Parsed protocol structures +# +# These are the structures that represent the parsed XML protocol definitions. + + +@dataclass +class EnumerationItem: + name: str + value: int + + +@dataclass +class EnumDefn: + protocol: str + name: str + items: list[EnumerationItem] + + +@dataclass +class XidTypeDefn: + protocol: str + name: str + + +@dataclass +class XidUnionDefn(LazyDefn): + protocol: str + name: str + types: list[str] + + +@dataclass +class TypedefDefn: + protocol: str + name: str + oldname: str + + +@dataclass +class Field: + name: str + type: str + enum: str | None = None + mask: str | None = None + + +@dataclass +class Pad: + bytes: int | None = None + align: int | None = None + + +@dataclass +class ListField: + name: str + type: str + enum: str | None = None + + +@dataclass +class FdField: + name: str + + +StructMember = Field | Pad | ListField | FdField + + +class StructLikeDefn(LazyDefn): + """Base class for struct-like definitions. + + This includes structs, requests, and replies, which are all similarly + structured. + """ + + def __init__(self, protocol: str, name: str, element: ET._Element) -> None: + super().__init__(protocol=protocol, name=name, element=element) + # Fields, padding, lists, in their original order + self.members: list[StructMember] + + @property + def fields(self) -> list[Field]: + self._ensure_parsed() + return [x for x in self.members if isinstance(x, Field)] + + @property + def lists(self) -> list[ListField]: + self._ensure_parsed() + return [x for x in self.members if isinstance(x, ListField)] + + def _parse_child(self, child: ET._Element) -> None: + """Parse a single child element of the struct-like definition. + + Subclasses are expected to override this to handle additional child + elements, but should call super() to handle the common ones. + """ + if isinstance(child, ET._Comment): # noqa: SLF001 + return + match child.tag: + case "field": + self.members.append( + Field( + name=child.attrib["name"], + type=child.attrib["type"], + enum=child.attrib.get("enum"), + mask=child.attrib.get("mask"), + ) + ) + case "fd": + self.members.append( + FdField( + name=child.attrib["name"], + ) + ) + case "pad": + self.members.append(parse_pad(child)) + case "list": + self.members.append(parse_list(child)) + case "doc": + return + case _: + msg = f"Unsupported member {child.tag} in {self.protocol}:{self.name}" + raise GenerationError( + msg, + element=child, + ) + + def _parse(self) -> None: + with parsing_note(f"while parsing {self.protocol}:{self.name}", self._element): + self.members = [] + for child in self._element: + self._parse_child(child) + + +class StructDefn(StructLikeDefn): + pass + + +class ReplyDefn(StructLikeDefn): + # Note that replies don't have their own name, so we use the request name. + pass + + +class RequestDefn(StructLikeDefn): + def __init__(self, protocol: str, name: str, element: ET._Element) -> None: + super().__init__(protocol=protocol, name=name, element=element) + self.reply: ReplyDefn | None + + def _parse_child(self, child: ET._Element) -> None: + if child.tag == "reply": + self.reply = ReplyDefn(self.protocol, self.name, child) + else: + super()._parse_child(child) + + def _parse(self) -> None: + self.reply = None + super()._parse() + + +TypeDefn = XidTypeDefn | XidUnionDefn | TypedefDefn | StructLikeDefn + + +# ---------------------------------------------------------------------------- +# Protocol container and lookups + + +@dataclass +class ProtocolModule: + name: str + version: tuple[int, int] | None + enums: dict[str, EnumDefn] = field(default_factory=dict) + types: dict[str, TypeDefn] = field(default_factory=dict) + requests: dict[str, RequestDefn] = field(default_factory=dict) + imports: list[str] = field(default_factory=list) + + +def parse_enum(protocol: str, elem: ET.Element) -> EnumDefn: + name = elem.attrib["name"] + with parsing_note(f"while parsing enum {name}", elem): + items: list[EnumerationItem] = [] + for item in elem.findall("item"): + if (value := item.find("value")) is not None: + items.append(EnumerationItem(item.attrib["name"], int(value.text, 0))) + elif (bit := item.find("bit")) is not None: + items.append(EnumerationItem(item.attrib["name"], 1 << int(bit.text, 0))) + else: + msg = f"Unsupported enum item in {protocol}:{name}:{item}" + raise GenerationError( + msg, + element=item, + ) + return EnumDefn(protocol, name, items) + + +def parse_xidunion(protocol: str, elem: ET.Element) -> XidUnionDefn: + name = elem.attrib["name"] + with parsing_note(f"while parsing xidunion {name}", elem): + members: list[str] = [] + for child in elem: + if isinstance(child, ET._Comment): # noqa: SLF001 + continue + if child.tag == "type": + if child.text is None: + msg = "xidunion type entry missing text" + raise GenerationError(msg, element=child) + members.append(child.text.strip()) + elif child.tag == "doc": + continue + else: + msg = f"Unsupported xidunion member {child.tag} in {protocol}:{name}" + raise GenerationError( + msg, + element=child, + ) + if not members: + msg = f"xidunion {protocol}:{name} must include at least one type" + raise GenerationError(msg, element=elem) + return XidUnionDefn(protocol, name, members) + + +def parse_list(elem: ET.Element) -> ListField: + return ListField(elem.attrib["name"], elem.attrib["type"], elem.attrib.get("enum")) + + +def parse_pad(elem: ET.Element) -> Pad: + with parsing_note("while parsing pad", elem): + bytes_attr = elem.attrib.get("bytes") + align_attr = elem.attrib.get("align") + if (bytes_attr is None) == (align_attr is None): + msg = "Pad must specify exactly one of 'bytes' or 'align'" + raise GenerationError(msg, element=elem) + if bytes_attr is not None: + return Pad(bytes=int(bytes_attr, 0)) + return Pad(align=int(align_attr, 0)) + + +def parse_protocol(path: Path) -> ProtocolModule: # noqa: PLR0912, PLR0915 + with parsing_note(f"while parsing protocol {path.name}"): + tree = ET.parse(path) + root = tree.getroot() + protocol = root.attrib["header"] + if "major-version" in root.attrib: + version = (int(root.attrib["major-version"]), int(root.attrib["minor-version"])) + else: + version = None + module = ProtocolModule(name=protocol, version=version) + for child in root: + if isinstance(child, ET._Comment): # noqa: SLF001 + continue + match child.tag: + case "enum": + if child.attrib["name"] in module.enums: + msg = f"Duplicate enum {child.attrib['name']} in protocol {protocol}" + raise GenerationError( + msg, + element=child, + ) + module.enums[child.attrib["name"]] = parse_enum(protocol, child) + case "typedef": + if child.attrib["newname"] in module.types: + msg = f"Duplicate type {child.attrib['newname']} in protocol {protocol}" + raise GenerationError( + msg, + element=child, + ) + module.types[child.attrib["newname"]] = TypedefDefn( + protocol, child.attrib["newname"], child.attrib["oldname"] + ) + case "xidtype": + if child.attrib["name"] in module.types: + msg = f"Duplicate type {child.attrib['name']} in protocol {protocol}" + raise GenerationError( + msg, + element=child, + ) + module.types[child.attrib["name"]] = XidTypeDefn(protocol, child.attrib["name"]) + case "xidunion": + if child.attrib["name"] in module.types: + msg = f"Duplicate type {child.attrib['name']} in protocol {protocol}" + raise GenerationError( + msg, + element=child, + ) + module.types[child.attrib["name"]] = parse_xidunion(protocol, child) + case "struct": + if child.attrib["name"] in module.types: + msg = f"Duplicate type {child.attrib['name']} in protocol {protocol}" + raise GenerationError( + msg, + element=child, + ) + module.types[child.attrib["name"]] = StructDefn(protocol, child.attrib["name"], child) + case "request": + if child.attrib["name"] in module.requests: + msg = f"Duplicate request {child.attrib['name']} in protocol {protocol}" + raise GenerationError( + msg, + element=child, + ) + module.requests[child.attrib["name"]] = RequestDefn(protocol, child.attrib["name"], child) + case "import": + # There's actually some leeway in how the imports are resolved. We only require the imported + # module to have been loaded if we need to check it for a type or enum. Since nearly everything + # is loaded from the same file or from xproto, it's rarely needed to do that explicitly. + module.imports.append(child.text.strip()) + case "union": + # We presently just don't use any unions (just xidunion). If they get used by something else, + # we'll end up raising an error at that time. + pass + case "error" | "errorcopy": + # We don't need any specialized error data. + pass + case "event" | "eventcopy": + # We don't use any events at present. + pass + case _: + msg = f"Unknown element {child.tag} in protocol {protocol}" + raise GenerationError(msg, element=child) + return module + + +class ProtocolRegistry: + """Holds every protocol module and provides lookup helpers.""" + + # This gets passed around a lot. It might be better to put it in a contextvar, if it gets burdensome. + + def __init__(self, proto_dir: Path) -> None: + self.modules: dict[str, ProtocolModule] = {} + self._load_all(proto_dir) + + def _load_all(self, proto_dir: Path) -> None: + for path in sorted(proto_dir.glob("*.xml")): + module = parse_protocol(path) + self.modules[module.name] = module + + def resolve_type(self, protocol: str, name: str) -> TypeDefn: + # Prefer the supplied protocol, then imports. + module = self.modules.get(protocol) + if not module: + msg = f"Unknown protocol {protocol} when resolving type {name}" + raise GenerationError(msg) + if name in module.types: + return module.types[name] + for imported_modname in module.imports: + imported_module = self.modules.get(imported_modname) + if imported_module is None: + msg = f"Module {protocol} imports {imported_modname}, which is not loaded" + raise GenerationError(msg) + if name in imported_module.types: + return imported_module.types[name] + msg = f"Unknown type {name} referenced from {protocol}" + raise GenerationError(msg) + + def resolve_enum(self, protocol: str, name: str) -> EnumDefn: + module = self.modules.get(protocol) + if not module: + msg = f"Unknown protocol {protocol} when resolving enum {name}" + raise GenerationError(msg) + if name in module.enums: + return module.enums[name] + for imported_modname in module.imports: + imported_module = self.modules.get(imported_modname) + if imported_module is None: + msg = f"Module {protocol} imports {imported_modname}, which is not loaded" + raise GenerationError(msg) + if name in imported_module.enums: + return imported_module.enums[name] + msg = f"Unknown enum {name} referenced from {protocol}" + raise GenerationError(msg) + + def resolve_request(self, protocol: str, name: str) -> RequestDefn: + if protocol not in self.modules: + msg = f"Unknown protocol {protocol} when resolving request {name}" + raise GenerationError(msg) + rv = self.modules[protocol].requests.get(name) + if rv is None: + msg = f"Request {protocol}:{name} not found" + raise GenerationError(msg) + return rv + + +# ---------------------------------------------------------------------------- +# Dependency analysis + + +@dataclass +class TopoSortResult: + enums: list[EnumDefn] + types: list[TypeDefn] + requests: list[RequestDefn] + + +def toposort_requirements( + registry: ProtocolRegistry, + type_requirements: Mapping[str, list[str]], + request_requirements: Mapping[str, list[str]], +) -> TopoSortResult: + rv = TopoSortResult([], [], []) + seen_types: set[tuple[str, str]] = set() + + def appendnew[T](collection: list[T], item: T) -> None: + if item not in collection: + collection.append(item) + + def require_member(protocol: str, member: StructMember) -> None: + if isinstance(member, (Field, ListField)): + require_type(protocol, member.type) + if member.enum: + enum = registry.resolve_enum(protocol, member.enum) + appendnew(rv.enums, enum) + elif isinstance(member, (FdField, Pad)): + pass + else: + msg = f"Unrecognized struct member {member}" + raise GenerationError(msg) + + def require_structlike(protocol: str, entry: StructLikeDefn) -> None: + for member in entry.members: + require_member(protocol, member) + + def require_type(protocol: str, name: str) -> None: + primitive = resolve_primitive(name) + if primitive: + return + entry = registry.resolve_type(protocol, name) + require_resolved_type(entry) + + def require_resolved_type(entry: TypeDefn) -> None: + key = (entry.protocol, entry.name) + if key in seen_types: + return + seen_types.add(key) + if isinstance(entry, XidUnionDefn): + # We put the union first as an XID, so that the subtypes can be derived from it. + appendnew(rv.types, entry) + for typ in entry.types: + require_type(protocol, typ) + elif isinstance(entry, StructLikeDefn): + require_structlike(entry.protocol, entry) + # The request types should all be handled by a different mechanism. + assert not isinstance(entry, RequestDefn) # noqa: S101 + appendnew(rv.types, entry) + else: + appendnew(rv.types, entry) + + for protocol, names in type_requirements.items(): + for name in names: + require_type(protocol, name) + + for protocol, names in request_requirements.items(): + for name in names: + request = registry.resolve_request(protocol, name) + require_structlike(protocol, request) + if request.reply: + require_resolved_type(request.reply) + appendnew(rv.requests, request) + + return rv + + +# ---------------------------------------------------------------------------- +# Code generation + + +@dataclass +class FuncDecl: + protocol: str + name: str + argtypes: list[str] + restype: str + + +class CodeWriter: + def __init__(self, fh: io.TextIOBase) -> None: + self._fh = fh + self._indent = 0 + + def write(self, line: str = "") -> None: + if line: + self._fh.write(" " * 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 | FdField) -> str: + name = field.name + return f"{name}_" if name in RESERVED_NAMES else name + + +def format_function_name(name: str, protocol: str | None = None) -> str: + return snake_case(name, protocol) + + +# Version constants + + +def emit_versions(writer: CodeWriter, registry: ProtocolRegistry) -> None: + writer.write() + for module in registry.modules.values(): + if module.version is None: + continue + const_prefix = module.name.upper() + writer.write(f"{const_prefix}_MAJOR_VERSION = {module.version[0]}") + writer.write(f"{const_prefix}_MINOR_VERSION = {module.version[1]}") + + +# Enums + + +def emit_enums(writer: CodeWriter, _registry: ProtocolRegistry, enums: Iterable[EnumDefn]) -> None: + enums = sorted(enums, key=lambda x: (x.protocol, x.name)) + writer.write() + writer.write("# Enum classes") + for defn in enums: + with parsing_note(f"while emitting enum {defn.protocol}:{defn.name}"): + class_name = format_enum_name(defn) + writer.write() + writer.write(f"class {class_name}(IntEnum):") + with writer.indent(): + for item in defn.items: + item_name = format_enum_item_name(item) + writer.write(f"{item_name} = {item.value}") + + +# Simple (non-struct-like) types + + +def python_type_for(registry: ProtocolRegistry, protocol: str, name: str) -> str: + primitive = resolve_primitive(name) + if primitive: + return primitive + entry = registry.resolve_type(protocol, name) + return format_type_name(entry) + + +# xidtypes interact with xidunions in kind of a backwards order. This is to make it possible to pass a Window to a +# function that expects a Drawable. Example output: +# . class Drawable(XID): pass +# . class Window(Drawable): pass +# . class Pixmap(Drawable): pass +# We can't use "Drawable = Window | Pixmap" because ctypes doesn't know what to do with that when used in argtypes. +def emit_xid( + writer: CodeWriter, + _registry: ProtocolRegistry, + entry: XidTypeDefn | XidUnionDefn, + derived_from: XidUnionDefn | None, +) -> None: + class_name = format_type_name(entry) + derived_from_name = format_type_name(derived_from) if derived_from is not None else "XID" + writer.write() + writer.write(f"class {class_name}({derived_from_name}):") + with writer.indent(): + writer.write("pass") + + +def emit_xidunion(writer: CodeWriter, registry: ProtocolRegistry, entry: XidUnionDefn) -> None: + emit_xid(writer, registry, entry, None) + + +def emit_typedef(writer: CodeWriter, registry: ProtocolRegistry, entry: TypedefDefn) -> None: + class_name = format_type_name(entry) + base = python_type_for(registry, entry.protocol, entry.oldname) + writer.write() + writer.write(f"class {class_name}({base}):") + with writer.indent(): + writer.write("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. + nonfd_members = [m for m in entry.members if not isinstance(m, FdField)] + + field_entries: list[tuple[str, str] | StructMember] = [("response_type", "c_uint8")] + if ( + nonfd_members + and isinstance(nonfd_members[0], Field) + and is_eight_bit(registry, entry.protocol, nonfd_members[0].type) + ): + member = nonfd_members.pop(0) + assert isinstance(member, Field) # noqa: S101 + name = format_field_name(member) + type_expr = python_type_for(registry, entry.protocol, member.type) + field_entries.append((name, type_expr)) + elif nonfd_members and (isinstance(nonfd_members[0], Pad) and nonfd_members[0].bytes == 1): + # XFixes puts the padding byte explicitly at the start of the replies, but it just gets folded in the same way. + member = nonfd_members.pop(0) + field_entries.append(member) + else: + field_entries.append(Pad(bytes=1)) + field_entries.append(("sequence", "c_uint16")) + field_entries.append(("length", "c_uint32")) + field_entries += nonfd_members + + return emit_structlike(writer, registry, entry, field_entries) + + +# Types + + +def emit_types( + writer: CodeWriter, + registry: ProtocolRegistry, + types: list[TypeDefn], +) -> list[FuncDecl]: + rv: list[FuncDecl] = [] + writer.write() + writer.write("# Generated ctypes structures") + + xid_derived_from: dict[tuple[str, str], XidUnionDefn] = {} + # We have to emit the unions first, so that we can emit the types that can comprise them as subtypes. + for union in types: + if not isinstance(union, XidUnionDefn): + continue + emit_xidunion(writer, registry, union) + for subtype_name in union.types: + subtype = registry.resolve_type(union.protocol, subtype_name) + subtype_key = (subtype.protocol, subtype.name) + if subtype_key in xid_derived_from: + # We could probably use multiple inheritance, but I don't have a test case. + msg = ( + f"XID {subtype.protocol}.{subtype.name} is used in multiple unions. This is" + "not currently supported." + ) + raise GenerationError(msg) + xid_derived_from[subtype_key] = union + + for typ in types: + with parsing_note(f"while emitting type {typ.protocol}:{typ.name}"): + if isinstance(typ, XidTypeDefn): + emit_xid(writer, registry, typ, xid_derived_from.get((typ.protocol, typ.name))) + elif isinstance(typ, XidUnionDefn): + pass + elif isinstance(typ, TypedefDefn): + emit_typedef(writer, registry, typ) + elif isinstance(typ, StructDefn): + rv += emit_structlike(writer, registry, typ) + elif isinstance(typ, ReplyDefn): + rv += emit_reply(writer, registry, typ) + else: + msg = f"Unsupported type kind {type(typ).__name__} for {typ.protocol}:{typ.name}" + raise GenerationError( + msg, + ) + return rv + + +# List wrappers + + +def emit_list_field( + writer: CodeWriter, registry: ProtocolRegistry, struct: StructLikeDefn, field: ListField +) -> list[FuncDecl]: + protocol = struct.protocol + base_func_name = f"{format_function_name(struct.name, protocol)}_{format_function_name(field.name)}" + outer_type_name = format_type_name(struct) + if field.type == "void": + # This means that we're getting a void*. Use an Array[c_char] instead of a c_void_p, so we have the length + # information. + inner_is_variable = False + inner_type_name = "c_char" + elif field.type in PRIMITIVE_CTYPES: + inner_is_variable = False + inner_type_name = PRIMITIVE_CTYPES[field.type] + else: + inner_type = registry.resolve_type(protocol, field.type) + inner_is_variable = type_is_variable(inner_type) + inner_type_name = format_type_name(inner_type) + lib = lib_for_proto(protocol) + if inner_is_variable: + iterator_type_name = f"{inner_type_name}Iterator" + xcb_iterator_func_name = f"xcb_{base_func_name}_iterator" + xcb_next_func_name = f"xcb_{format_function_name(field.type, struct.protocol)}_next" + writer.write() + writer.write(f"def {base_func_name}(r: {outer_type_name}) -> list[{inner_type_name}]:") + with writer.indent(): + writer.write(f"return list_from_xcb(LIB.{lib}.{xcb_iterator_func_name}, LIB.{lib}.{xcb_next_func_name}, r)") + return [ + FuncDecl(lib, xcb_iterator_func_name, [f"POINTER({outer_type_name})"], iterator_type_name), + # The "next" function was defined alongside the iterator type. + ] + xcb_array_pointer_func_name = f"xcb_{base_func_name}" + xcb_array_length_func_name = f"xcb_{base_func_name}_length" + writer.write() + writer.write(f"def {base_func_name}(r: {outer_type_name}) -> Array[{inner_type_name}]:") + with writer.indent(): + writer.write( + f"return array_from_xcb(LIB.{lib}.{xcb_array_pointer_func_name}, LIB.{lib}.{xcb_array_length_func_name}, r)" + ) + return [ + FuncDecl(lib, xcb_array_pointer_func_name, [f"POINTER({outer_type_name})"], f"POINTER({inner_type_name})"), + FuncDecl(lib, xcb_array_length_func_name, [f"POINTER({outer_type_name})"], XCB_LENGTH_TYPE), + ] + + +def emit_lists(writer: CodeWriter, registry: ProtocolRegistry, types: list[TypeDefn]) -> list[FuncDecl]: + rv: list[FuncDecl] = [] + for typ in types: + if not isinstance(typ, StructLikeDefn): + continue + for list_field in typ.lists: + rv += emit_list_field(writer, registry, typ, list_field) + return rv + + +# File descriptor accessor wrappers + + +def emit_fds(writer: CodeWriter, _registry: ProtocolRegistry, types: list[TypeDefn]) -> list[FuncDecl]: + rv: list[FuncDecl] = [] + for typ in types: + if not isinstance(typ, StructLikeDefn): + continue + fd_members = [m for m in typ.members if isinstance(m, FdField)] + if not fd_members: + continue + if len(fd_members) > 1: + # I simply don't know how this would be represented in libxcb. + msg = f"Struct {typ.protocol}:{typ.name} has multiple FdFields, which is unsupported" + raise GenerationError(msg) + # The way that the reply fd accessor is named is not that great: + # rather than having a function named after the field, it's named with just an "_fd" suffix. + func_name = f"{format_function_name(typ.name, typ.protocol)}_reply_fds" + writer.write() + writer.write( + f"def {func_name}(c: Connection | _Pointer[Connection], r: {format_type_name(typ)}) -> _Pointer[c_int]:" + ) + with writer.indent(): + writer.write(f"return LIB.{lib_for_proto(typ.protocol)}.xcb_{func_name}(c, r)") + rv.append( + FuncDecl( + typ.protocol, + f"xcb_{func_name}", + ["POINTER(Connection)", f"POINTER({format_type_name(typ)})"], + "POINTER(c_int)", + ) + ) + return rv + + +# Request wrapper functions + + +def emit_requests(writer: CodeWriter, registry: ProtocolRegistry, requests: list[RequestDefn]) -> list[FuncDecl | str]: + rv: list[FuncDecl | str] = [] + for request in requests: + lib = lib_for_proto(request.protocol) + func_name = format_function_name(request.name, request.protocol) + xcb_func_name = f"xcb_{func_name}" + if request.lists: + msg = "Cannot handle requests with lists at present" + raise GenerationError(msg) + # Parameters are the inputs you declare in the function's "def" line. Arguments are the inputs you provide to + # a function when you call it. + params: list[tuple[str, str]] = [("c", "Connection")] + params += [ + ( + format_field_name(field), + python_type_for(registry, request.protocol, field.type) if isinstance(field, Field) else "c_int", + ) + for field in request.members + if not isinstance(field, (Pad, ListField)) + ] + params_types = [p[1] for p in params] + # Arrange for the wrappers to take Python ints in place of any of the int-based ctypes. + params_with_alts = [(p[0], f"{p[1]} | int" if p[1] in INT_CTYPES else p[1]) for p in params] + params_string = ", ".join(f"{p[0]}: {p[1]}" for p in params_with_alts) + args_string = ", ".join(p[0] for p in params) + xcb_params_types = ["POINTER(Connection)", *params_types[1:]] + if request.reply is None: + xcb_func_name += "_checked" + writer.write() + writer.write(f"def {func_name}({params_string}) -> None:") + with writer.indent(): + writer.write(f"return LIB.{lib}.{xcb_func_name}({args_string}).check(c)") + rv.append(FuncDecl(request.protocol, xcb_func_name, xcb_params_types, "VoidCookie")) + else: + reply_type = request.reply + reply_type_name = format_type_name(reply_type) + writer.write() + writer.write(f"def {func_name}({params_string}) -> {reply_type_name}:") + with writer.indent(): + writer.write(f"return LIB.{lib}.{xcb_func_name}({args_string}).reply(c)") + # We have to use initialize_xcb_typed_func to initialize late, rather than making the cookie class here, + # because the cookie definition needs to reference the XCB reply function. We could also do a lazy + # initialization, but it's probably not worth it. + rv.append( + f'initialize_xcb_typed_func(LIB.{lib}, "{xcb_func_name}", ' + f"[{', '.join(xcb_params_types)}], {reply_type_name})" + ) + return rv + + +# Initializer function + + +def emit_initialize(writer: CodeWriter, func_decls: list[FuncDecl | str]) -> None: + writer.write() + writer.write("def initialize() -> None: # noqa: PLR0915") + with writer.indent(): + for decl in func_decls: + if isinstance(decl, str): + writer.write(decl) + else: + lib = lib_for_proto(decl.protocol) + writer.write(f"LIB.{lib}.{decl.name}.argtypes = ({', '.join(decl.argtypes)},)") + writer.write(f"LIB.{lib}.{decl.name}.restype = {decl.restype}") + + +# Top level code generator + + +def generate( + output: io.TextIOBase, + proto_dir: Path, + type_requirements: Mapping[str, list[str]] | None = None, + request_requirements: Mapping[str, list[str]] | None = None, +) -> None: + registry = ProtocolRegistry(proto_dir) + type_requirements = type_requirements or TYPES + request_requirements = request_requirements or REQUESTS + plan = toposort_requirements(registry, type_requirements, request_requirements) + + func_decls: list[FuncDecl | str] = [] + + writer = CodeWriter(output) + writer.write(GENERATED_HEADER.rstrip()) + emit_versions(writer, registry) + emit_enums(writer, registry, plan.enums) + func_decls += emit_types(writer, registry, plan.types) + func_decls += emit_lists(writer, registry, plan.types) + func_decls += emit_fds(writer, registry, plan.types) + func_decls += emit_requests(writer, registry, plan.requests) + emit_initialize(writer, func_decls) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate ctypes bindings from XCB protocol XML") + parser.add_argument("--proto_dir", type=Path, default=Path(__file__).resolve().parent) + parser.add_argument("--output_path", type=Path, default=Path("xcbgen.py")) + args = parser.parse_args(argv) + with args.output_path.open("w") as fh: + generate(fh, args.proto_dir, TYPES, REQUESTS) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/xcbproto/randr.xml b/src/xcbproto/randr.xml new file mode 100644 index 00000000..64fa2d44 --- /dev/null +++ b/src/xcbproto/randr.xml @@ -0,0 +1,954 @@ + + + + + + xproto + render + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + nRates + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + 1 + 2 + 3 + + 4 + 5 + 6 + 7 + + + + + + + + + + + + + + + + + + + + + + + + nSizes + + + + nInfo + nSizes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_crtcs + + + num_outputs + + + num_modes + + + + names_len + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + num_crtcs + + + num_modes + + + num_clones + + + name_len + + + + + + + + + + + + num_atoms + + + + + + + + + + + + + + + length + + + + + + + + + + + + + + + + + + + + + + + + + num_units + format + + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_items + + format + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_outputs + + + num_possible_outputs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + size + + + size + + + size + + + + + + + + + + size + + + size + + + size + + + + + + + + + + + + + + + + + + num_crtcs + + + num_outputs + + + num_modes + + + + names_len + + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + filter_len + + + + + + + + + + + + + + + + + + + + pending_len + + + + pending_nparams + + + current_len + + + + current_nparams + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_providers + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + + num_crtcs + + + num_outputs + + + num_associated_providers + + + num_associated_providers + + + name_len + + + + + + + + + + + + + + + + + + + + + + + + + num_atoms + + + + + + + + + + + + + + + length + + + + + + + + + + + + + + + + + + + + + + + + num_items + + format + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_items + + format + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nOutput + + + + + + + + + + + + + + nMonitors + + + + + + + + + + + + + + + + + + + + + + + num_crtcs + + + num_outputs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/xcbproto/render.xml b/src/xcbproto/render.xml new file mode 100644 index 00000000..7bee25ec --- /dev/null +++ b/src/xcbproto/render.xml @@ -0,0 +1,693 @@ + + + + + + xproto + + + 0 + 1 + + + + 0 + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + 25 + 26 + 27 + + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + + + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + + + + 0 + 1 + + + + 0 + 1 + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_visuals + + + + + + + + num_depths + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_formats + + + num_screens + + + num_subpixel + + + + + + + + + + + + + num_values + + + + + + + + + + + + + value_mask + + Repeat + + + + AlphaMap + + + + AlphaXOrigin + + + + AlphaYOrigin + + + + ClipXOrigin + + + + ClipYOrigin + + + + ClipMask + + + + GraphicsExposure + + + + SubwindowMode + + + + PolyEdge + + + + PolyMode + + + + Dither + + + + ComponentAlpha + + + + + + + + + + value_mask + + Repeat + + + + AlphaMap + + + + AlphaXOrigin + + + + AlphaYOrigin + + + + ClipXOrigin + + + + ClipYOrigin + + + + ClipMask + + + + GraphicsExposure + + + + SubwindowMode + + + + PolyEdge + + + + PolyMode + + + + Dither + + + + ComponentAlpha + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + glyphs_len + + + glyphs_len + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_aliases + + + num_filters + + + + + + + + + + filter_len + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + num_stops + + + num_stops + + + + + + + + + + + + num_stops + + + num_stops + + + + + + + + + + num_stops + + + num_stops + + + diff --git a/src/xcbproto/shm.xml b/src/xcbproto/shm.xml new file mode 100644 index 00000000..c1114e0a --- /dev/null +++ b/src/xcbproto/shm.xml @@ -0,0 +1,350 @@ + + + + xproto + + + + + + + + + + + + + Report that an XCB_SHM_PUT_IMAGE request has completed + + + + + + + + + + + + + + + + + + + + + + The version of the MIT-SHM extension supported by the server + + + + + The UID of the server. + The GID of the server. + + + + Query the version of the MIT-SHM extension. + + + + + + + + + + + Attach a System V shared memory segment. + + + + + + + + + + + Destroys the specified shared memory segment. + + The segment to be destroyed. + + + + + + + + + + + + + + + + + + + + + + Copy data from the shared memory to the specified drawable. + + The drawable to draw to. + The graphics context to use. + The total width of the source image. + The total height of the source image. + The source X coordinate of the sub-image to copy. + The source Y coordinate of the sub-image to copy. + + + + + The depth to use. + + + The offset that the source image starts at. + + + + + + + + + + + + + + + + + + + + + Indicates the result of the copy. + + The depth of the source drawable. + The visual ID of the source drawable. + The number of bytes copied. + + + + Copies data from the specified drawable to the shared memory segment. + + The drawable to copy the image out of. + The X coordinate in the drawable to begin copying at. + The Y coordinate in the drawable to begin copying at. + The width of the image to copy. + The height of the image to copy. + A mask that determines which planes are used. + The format to use for the copy (???). + The destination shared memory segment. + The offset in the shared memory segment to copy data to. + + + + + + + + + + + + + + Create a pixmap backed by shared memory. + +Create a pixmap backed by shared memory. Writes to the shared memory will be +reflected in the contents of the pixmap, and writes to the pixmap will be +reflected in the contents of the shared memory. + + A pixmap ID created with xcb_generate_id(). + The drawable to create the pixmap in. + + + + + + + + + + + + + + + Create a shared memory segment + + + The file descriptor the server should mmap(). + + + + + + + + + + + + + + + The returned file descriptor. + + + + + + Asks the server to allocate a shared memory segment. + + + The size of the segment to create. + + + + diff --git a/src/xcbproto/xfixes.xml b/src/xcbproto/xfixes.xml new file mode 100644 index 00000000..5e54c420 --- /dev/null +++ b/src/xcbproto/xfixes.xml @@ -0,0 +1,405 @@ + + + + + xproto + render + shape + + + + + + + + + + + + + + + 0 + 1 + + + + 0 + 1 + + + + 0 + 1 + + + + + + + + + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + 0 + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + width + height + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + length + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nbytes + + + + + + + + + + nbytes + + + + + + + + + + + + + + + + + + + width + height + + + nbytes + + + + + + + + + + + + + nbytes + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + num_devices + + + + + + + + + + 0 + 0 + + + + + + + + + + Sets the disconnect mode for the client. + + + + + + + + + + + Gets the disconnect mode for the client. + + + + + diff --git a/src/xcbproto/xproto.xml b/src/xcbproto/xproto.xml new file mode 100644 index 00000000..9a0245a4 --- /dev/null +++ b/src/xcbproto/xproto.xml @@ -0,0 +1,5637 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + WINDOW + PIXMAP + + + + FONT + GCONTEXT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + visuals_len + + + + + 0 + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + allowed_depths_len + + + + + + + + + + + + + authorization_protocol_name_len + + + + authorization_protocol_data_len + + + + + + + + + + + + reason_len + + + + + + + + + + length + 4 + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + vendor_len + + + + pixmap_formats_len + + + roots_len + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 15 + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + + + + 0 + + + + + + + + + + + + + + + + + a key was pressed/released + + + + + + + + + + + + + + + + + + 8 + 9 + 10 + 11 + 12 + 15 + + + + + + + + + + + + + + + + + a mouse button was pressed/released + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + a key was pressed + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + + + the pointer is in a different window + + + + + + + + + + + + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + + 31 + + + + + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + a window is destroyed + + + + + + + + + + + + + + a window is unmapped + + + + + + + + + + + + + + + a window was mapped + + + + + + + + + + + + + window wants to be mapped + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + a window property changed + + + + + + + + + + + + + + + + + + 0 + + + + 0 + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + 23 + 24 + 25 + 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + 0 + + + + + + + + + + + the colormap for some window changed + + + + + + + + + + + + 20 + 10 + 5 + + + + + + + + + NOT YET DOCUMENTED + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + keyboard mapping changed + + + + + + + + + + + generic event (with length) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + 0 + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + + + + + + + + + + + + + + + + value_mask + + BackPixmap + + + + BackPixel + + + + BorderPixmap + + + + BorderPixel + + + + BitGravity + + + + WinGravity + + + + BackingStore + + + + BackingPlanes + + + + BackingPixel + + + + OverrideRedirect + + + + SaveUnder + + + + EventMask + + + + DontPropagate + + + + Colormap + + + + Cursor + + + + + + Creates a window + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + value_mask + + BackPixmap + + + + BackPixel + + + + BorderPixmap + + + + BorderPixel + + + + BitGravity + + + + WinGravity + + + + BackingStore + + + + BackingPlanes + + + + BackingPixel + + + + OverrideRedirect + + + + SaveUnder + + + + EventMask + + + + DontPropagate + + + + Colormap + + + + Cursor + + + + + + change window attributes + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gets window attributes + + + + + + + + + + + + + Destroys a window + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + Changes a client's save set + + + + + + + + + + + + + + + + + + Reparents a window + + + + + + + + + + + + + + + + + + Makes a window visible + + + + + + + + + + + + + + + + + + + Makes a window invisible + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + + + + 0 + 1 + 2 + 3 + 4 + + + + + + + + + value_mask + + X + + + + Y + + + + Width + + + + Height + + + + BorderWidth + + + + Sibling + + + + StackMode + + + + + + Configures window attributes + + + + + + + + + + + + + + + 0 + 1 + + + + + + + Change window stacking order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Get current window geometry + + x, reply->y); + } + free(reply); +} + ]]> + + + + + + + + + + + + + + + + + + children_len + + + + + + + + + query the window tree + + root); + printf("parent = 0x%08x\\n", reply->parent); + + xcb_window_t *children = xcb_query_tree_children(reply); + for (int i = 0; i < xcb_query_tree_children_length(reply); i++) + printf("child window = 0x%08x\\n", children[i]); + + free(reply); + } +} + ]]> + + + + + + + + + + + name_len + + + + + + + + Get atom identifier by name + + atom); + free(reply); + } +} + ]]> + + + + + + + + + + + + + + + + + + + + name_len + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + data_len + format + + 8 + + + + Changes a window property + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + value_len + + format + 8 + + + + + + + + + + + + + Gets a window property + + + + + + + + + + + + + + + + + + + + + + + + + atoms_len + + + + + + + + + + + Sets the owner of a selection + + + + + + + + + + + + + + + + + + + + + + + Gets the owner of a selection + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + 32 + + send an event + + event = window; + event->window = window; + event->response_type = XCB_CONFIGURE_NOTIFY; + + event->x = 0; + event->y = 0; + event->width = 800; + event->height = 600; + + event->border_width = 0; + event->above_sibling = XCB_NONE; + event->override_redirect = false; + + xcb_send_event(conn, false, window, XCB_EVENT_MASK_STRUCTURE_NOTIFY, + (char*)event); + xcb_flush(conn); + free(event); +} + ]]> + + + + + + + + + + + + 0 + 1 + + + + + + + + 0 + 1 + 2 + 3 + 4 + + + + 0 + + + + + + + + + + + + + + + + + Grab the pointer + + root, /* grab the root window */ + XCB_NONE, /* which events to let through */ + XCB_GRAB_MODE_ASYNC, /* pointer events should continue as normal */ + XCB_GRAB_MODE_ASYNC, /* keyboard mode */ + XCB_NONE, /* confine_to = in which window should the cursor stay */ + cursor, /* we change the cursor to whatever the user wanted */ + XCB_CURRENT_TIME + ); + + if ((reply = xcb_grab_pointer_reply(conn, cookie, NULL))) { + if (reply->status == XCB_GRAB_STATUS_SUCCESS) + printf("successfully grabbed the pointer\\n"); + free(reply); + } +} + ]]> + + + + + + + + + + + + + + + + + + + + + release the pointer + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + + + + Grab pointer button(s) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Grab the keyboard + + root, /* grab the root window */ + XCB_CURRENT_TIME, + XCB_GRAB_MODE_ASYNC, /* process events as normal, do not require sync */ + XCB_GRAB_MODE_ASYNC + ); + + if ((reply = xcb_grab_keyboard_reply(conn, cookie, NULL))) { + if (reply->status == XCB_GRAB_STATUS_SUCCESS) + printf("successfully grabbed the keyboard\\n"); + + free(reply); + } +} + ]]> + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + Grab keyboard key(s) + + + + + + + + + + + + + + + + + + + + + + release a key combination + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + + + + + + + + + + + + + + + + release queued events + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + get pointer coordinates + + + + + + + + + + + + + + + + + + + + + + + events_len + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + move mouse pointer + + + + + + + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + Sets input focus + + + + + + + + + + + + + + + + + + + + + + + + + 32 + + + + + + + + + + name_len + + + opens a font + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + properties_len + + + char_infos_len + + + + + + + + + + + + + + + + + query font metrics + + + + + + + + + string_len1 + + + + + + + + + + + + + + + + get text extents + + + + + + + + + + + + + name_len + + + + + + + + + pattern_len + + + + + + + names_len + + + + + + + + get matching font names + + + + + + + + + + + + + pattern_len + + + + + + + + + + + + + + + + + + + + properties_len + + + name_len + + + + + + + + + + + + + + + + + + + get matching font names and information + + + + + + + + + + + + + + font_qty + + + + + + + + + + path_len + + + + + + + + + + + + + Creates a pixmap + + + + + + + + + + + + + + + + + + Destroys a pixmap + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + 22 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + 3 + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + 3 + + + + 0 + 1 + + + + 0 + 1 + + + + 0 + 1 + + + + + + + + + value_mask + + Function + + + + PlaneMask + + + + Foreground + + + + Background + + + + LineWidth + + + + LineStyle + + + + CapStyle + + + + JoinStyle + + + + FillStyle + + + + FillRule + + + + Tile + + + + Stipple + + + + TileStippleOriginX + + + + TileStippleOriginY + + + + Font + + + + SubwindowMode + + + + GraphicsExposures + + + + ClipOriginX + + + + ClipOriginY + + + + ClipMask + + + + DashOffset + + + + DashList + + + + ArcMode + + + + + Creates a graphics context + + + + + + + + + + + + + + + + + + + value_mask + + Function + + + + PlaneMask + + + + Foreground + + + + Background + + + + LineWidth + + + + LineStyle + + + + CapStyle + + + + JoinStyle + + + + FillStyle + + + + FillRule + + + + Tile + + + + Stipple + + + + TileStippleOriginX + + + + TileStippleOriginY + + + + Font + + + + SubwindowMode + + + + GraphicsExposures + + + + ClipOriginX + + + + ClipOriginY + + + + ClipMask + + + + DashOffset + + + + DashList + + + + ArcMode + + + + + change graphics context components + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dashes_len + + + + + 0 + 1 + 2 + 3 + + + + + + + + + + + + + + + Destroys a graphics context + + + + + + + + + + + + + + + + + + + + + + + + + + + + copy areas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + + + + draw lines + + + + + + + + + + + + + + + + + + + + + + + + + + + + + draw lines + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + Fills rectangles + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + length + 4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + string_len + + + Draws text + + + + + + + + + + + + + + + + + + + + + + string_len + + + Draws text + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cmaps_len + + + + + + + + + + + + + + + + + + + + + + Allocate a color + + + + + + + + + + + + + + + + name_len + + + + + + + + + + + + + + + + + + + + + + + + + + pixels_len + + + masks_len + + + + + + + + + + + + + + + + + + + + + pixels_len + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + + + name_len + + + + + + + + + + + + + + + + + + + + colors_len + + + + + + + + + + + name_len + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + create cursor + + + + + + + + + + + + + + + + + + + + + + + + Deletes a cursor + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + name_len + + + + + + + + + + + + + + + + + check if extension is present + + + + + + + + + + + + + + names_len + + + + + + + + + + + + + keycode_count + keysyms_per_keycode + + + + + + + + + + + + + length + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + + + 0 + 1 + + + + 0 + 1 + 2 + + + + + + + value_mask + + KeyClickPercent + + + + BellPercent + + + + BellPitch + + + + BellDuration + + + + Led + + + + LedMode + + + + Key + + + + AutoRepeatMode + + + + + + + + + + + + + + + + 32 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + 0 + 1 + 2 + 5 + 6 + + + + + + + + + address_len + + + + + + + + + address_len + + + + + + + + + + + hosts_len + + + + + + 0 + 1 + + + + + + + + 0 + 1 + 2 + + + + + + + + 0 + + + + + + + kills a client + + + + + + + + + + + + + + + atoms_len + + + + + 0 + 1 + + + + + + + + + 0 + 1 + 2 + + + + + + map_len + + + + + + + + + + + + + map_len + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + + + + + + keycodes_per_modifier + 8 + + + + + + + + + + + + + + + keycodes_per_modifier + 8 + + + + + + + + + diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 13bfba07..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import glob -import os - -import mss -import pytest - - -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(): - with open("tests/res/monitor-1024x768.raw", "rb") as f: - yield f.read() diff --git a/tests/res/monitor-1024x768.raw b/tests/res/monitor-1024x768.raw deleted file mode 100644 index 65a1c720..00000000 Binary files a/tests/res/monitor-1024x768.raw and /dev/null differ diff --git a/tests/test_cls_image.py b/tests/test_cls_image.py deleted file mode 100644 index a3b198cc..00000000 --- a/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/tests/test_find_monitors.py b/tests/test_find_monitors.py deleted file mode 100644 index c5b15695..00000000 --- a/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/tests/test_get_pixels.py b/tests/test_get_pixels.py deleted file mode 100644 index 4672a20e..00000000 --- a/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): - 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 - assert image.height == 160 - - -def test_grab_part_of_screen_rounded(sct): - 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 - assert image.height == 159 - - -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/tests/test_gnu_linux.py b/tests/test_gnu_linux.py deleted file mode 100644 index 328fde1c..00000000 --- a/tests/test_gnu_linux.py +++ /dev/null @@ -1,124 +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 MSSMixin -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, MSSMixin) - monkeypatch.undo() - - # macOS - monkeypatch.setattr(platform, "system", lambda: "Darwin") - with pytest.raises(ScreenShotError): - mss.mss() - monkeypatch.undo() - - # Windows - monkeypatch.setattr(platform, "system", lambda: "wInDoWs") - with pytest.raises(ValueError): - # wintypes.py:19: ValueError: _type_ 'v' not supported - 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") - monitor = {"left": -30, "top": 0, "width": 100, "height": 100} - - with mss.mss(display=display) as sct: - with pytest.raises(ScreenShotError) as exc: - 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) diff --git a/tests/test_implementation.py b/tests/test_implementation.py deleted file mode 100644 index 11bfdf49..00000000 --- a/tests/test_implementation.py +++ /dev/null @@ -1,183 +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 sys - -import mss -import mss.tools -from mss.base import MSSMixin -from mss.exception import ScreenShotError -from mss.screenshot import ScreenShot - -import pytest - - -PY3 = sys.version[0] > "2" - - -class MSS0(MSSMixin): - """ Nothing implemented. """ - - pass - - -class MSS1(MSSMixin): - """ Emulate no monitors. """ - - @property - def monitors(self): - return [] - - -class MSS2(MSSMixin): - """ Emulate one monitor. """ - - @property - def monitors(self): - return [{"top": 0, "left": 0, "width": 10, "height": 10}] - - -def test_incomplete_class(): - # `monitors` property not implemented - with pytest.raises(NotImplementedError): - for filename in MSS0().save(): - assert os.path.isfile(filename) - - # `monitors` property is empty - with pytest.raises(ScreenShotError): - for filename in MSS1().save(): - assert os.path.isfile(filename) - - # `grab()` not implemented - sct = MSS2() - with pytest.raises(NotImplementedError): - sct.grab(sct.monitors[0]) - - # Bad monitor - with pytest.raises(ScreenShotError): - sct.grab(sct.shot(mon=222)) - - -def test_repr(sct): - box = {"top": 0, "left": 0, "width": 10, "height": 10} - img = sct.grab(box) - ref = ScreenShot(bytearray(b"42"), box) - assert repr(img) == repr(ref) - - -def test_factory(monkeypatch): - # Current system - with mss.mss() as sct: - assert isinstance(sct, MSSMixin) - - # Unknown - monkeypatch.setattr(platform, "system", lambda: "Chuck Norris") - with pytest.raises(ScreenShotError) as exc: - mss.mss() - monkeypatch.undo() - - if not PY3: - error = exc.value[0] - else: - 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]) - filename = fmt.format(**sct.monitors[1]) - out, _ = capsys.readouterr() - assert out.endswith(filename + "\n") - 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): - 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, 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(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 diff --git a/tests/test_leaks.py b/tests/test_leaks.py deleted file mode 100644 index bd4bf9bd..00000000 --- a/tests/test_leaks.py +++ /dev/null @@ -1,107 +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() - - -@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, - ), -) -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/tests/test_macos.py b/tests/test_macos.py deleted file mode 100644 index 9a6f8871..00000000 --- a/tests/test_macos.py +++ /dev/null @@ -1,65 +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 - 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/tests/test_save.py b/tests/test_save.py deleted file mode 100644 index bc4fbb28..00000000 --- a/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/tests/test_third_party.py b/tests/test_third_party.py deleted file mode 100644 index caca9814..00000000 --- a/tests/test_third_party.py +++ /dev/null @@ -1,81 +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: - 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): - box = {"top": 0, "left": 0, "width": 10, "height": 10} - img = numpy.array(sct.grab(box)) - assert len(img) == 10 - - -@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/tests/test_tools.py b/tests/test_tools.py deleted file mode 100644 index ecdea05d..00000000 --- a/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/tests/test_windows.py b/tests/test_windows.py deleted file mode 100644 index 31904336..00000000 --- a/tests/test_windows.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss -""" - -import platform - -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() diff --git a/tox.ini b/tox.ini deleted file mode 100644 index cfa330d9..00000000 --- a/tox.ini +++ /dev/null @@ -1,42 +0,0 @@ -[tox] -envlist = - lint - types - docs - py{38,37,36,35,py3} -skip_missing_interpreters = True - -[testenv] -passenv = DISPLAY -alwayscopy = True -deps = - pytest - # Must pin that version to support PyPy3 - numpy==1.15.4 - pillow -commands = - python -m pytest {posargs} - -[testenv:lint] -description = Code quality check -deps = - flake8 - pylint -commands = - python -m flake8 docs mss tests - 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 tests 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')"