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/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..871822d8 --- /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@v4 + - name: Install Python + uses: actions/setup-python@v5 + 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..fa93b929 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,99 @@ +name: Tests + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + quality: + name: Quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + 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@v4 + - uses: actions/setup-python@v5 + 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@v4 + - uses: actions/setup-python@v5 + 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/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..ed065c83 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "languageToolLinter.languageTool.ignoredWordsInWorkspace": [ + "bgra", + "ctypes", + "eownis", + "memoization", + "noop", + "numpy", + "oros", + "pylint", + "pypy", + "python-mss", + "pythonista", + "sdist", + "sourcery", + "tk", + "tkinter", + "xlib", + "xrandr", + "xserver", + "zlib" + ] +} \ No newline at end of file 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..f0da0127 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,307 @@ +# History + +See Git checking messages for full history. + +## 10.1.0.dev0 (2025-xx-xx) +- 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) +- :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..f1030bd0 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,224 @@ +# Technical Changes + +## 10.1.0 (2025-xx-xx) + +### 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..d81a79ce --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# 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 +``` 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..b1d87f4b 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -5,90 +5,153 @@ 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 +.. attribute:: CFUNCTIONS - :type: types.SimpleNamspacedict + .. versionadded:: 6.1.0 - The `details` attribute contains the latest Xlib or XRANDR function. It is a dict. +.. attribute:: PLAINMASK - .. versionadded:: 5.0.0 +.. attribute:: ZPIXMAP -.. class:: MSS +.. class:: Display - .. method:: __init__([display=None]) + Structure that serves as the connection to the X server, and that contains all the information about that X server. - :type display: str or None - :param display: The display to use. +.. class:: XErrorEvent - GNU/Linux initializations. + XErrorEvent to debug eventual errors. - .. method:: get_error_details() +.. class:: XFixesCursorImage - :rtype: Optional[dict[str, Any]] + Cursor structure - Get more information about the latest X server error. To use in such scenario:: +.. class:: XImage - with mss.mss() as sct: - # Take a screenshot of a region out of monitor bounds - rect = {"left": -30, "top": 0, "width": 100, "height": 100} + Description of an image as it exists in the client's memory. - try: - sct.grab(rect) - except ScreenShotError: - details = sct.get_error_details() - """ - >>> import pprint - >>> pprint.pprint(details) - {'xerror': 'BadFont (invalid Font parameter)', - 'xerror_details': {'error_code': 7, - 'minor_code': 0, - 'request_code': 0, - 'serial': 422, - 'type': 0}} - """ +.. class:: XRRCrtcInfo - .. versionadded:: 4.0.0 + Structure that contains CRTC information. + +.. class:: XRRModeInfo + +.. class:: XRRScreenResources + + Structure that contains arrays of XIDs that point to the available outputs and associated CRTCs. + +.. class:: XWindowAttributes + + Attributes for the specified window. + +.. class:: MSS + + .. method:: close() - .. method:: grab(monitor) + Clean-up method. - :rtype: :class:`~mss.base.ScreenShot` - :raises ScreenShotError: When color depth is not 32 (rare). + .. versionadded:: 8.0.0 - See :meth:`~mss.base.MSSMixin.grab()` for details. +Windows +------- -.. function:: error_handler(display, event) +.. module:: mss.windows - :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``. +.. attribute:: CAPTUREBLT - 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:: CFUNCTIONS - 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. + .. versionadded:: 6.1.0 - .. versionadded:: 3.3.0 +.. attribute:: DIB_RGB_COLORS +.. attribute:: SRCCOPY + +.. class:: BITMAPINFOHEADER + +.. class:: BITMAPINFO + +.. attribute:: MONITORNUMPROC + + .. versionadded:: 6.1.0 + +.. class:: MSS + + .. attribute:: gdi32 + + .. attribute:: user32 Methods ======= .. module:: mss.base -.. 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 +159,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 +173,18 @@ Methods :param int mon: the monitor's number. :param str output: the output's file name. :type callback: callable or None - :param callback: callback called before saving the screen shot to a file. Takes the *output* argument as parameter. + :param callback: callback called before saving the screenshot to a file. Takes the *output* argument as parameter. :rtype: iterable :return: Created file(s). - Grab a screen shot and save it to a file. + Grab a screenshot and save it to a file. The *output* parameter can take several keywords to customize the filename: - ``{mon}``: the monitor number - - ``{top}``: the screen shot y-coordinate of the upper-left corner - - ``{left}``: the screen shot x-coordinate of the upper-left corner - - ``{width}``: the screen shot's width - - ``{height}``: the screen shot's height + - ``{top}``: the screenshot y-coordinate of the upper-left corner + - ``{left}``: the screenshot x-coordinate of the upper-left corner + - ``{width}``: the screenshot's width + - ``{height}``: the screenshot's height - ``{date}``: the current date using the default formatter As it is using the :py:func:`format()` function, you can specify formatting options like ``{date:%Y-%m-%s}``. @@ -136,14 +199,14 @@ Methods :return str: The created file. - Helper to save the screen shot of the first monitor, by default. + Helper to save the screenshot of the first monitor, by default. You can pass the same arguments as for :meth:`save()`. .. versionadded:: 3.0.0 .. class:: ScreenShot - Screen shot object. + Screenshot object. .. note:: @@ -158,7 +221,7 @@ Methods :param int height: the monitor's height. :rtype: :class:`ScreenShot` - Instantiate a new class given only screen shot's data and size. + Instantiate a new class given only screenshot's data and size. .. method:: pixel(coord_x, coord_y) @@ -194,7 +257,7 @@ Methods Properties ========== -.. class:: mss.base.MSSMixin +.. class:: mss.base.MSSBase .. attribute:: monitors @@ -215,6 +278,8 @@ Properties - ``width``: the width - ``height``: the height + Subclasses need to implement this. + :rtype: list[dict[str, int]] .. class:: mss.base.ScreenShot @@ -235,25 +300,25 @@ Properties .. attribute:: height - The screen shot's height. + The screenshot's height. :rtype: int .. attribute:: left - The screen shot's left coordinate. + The screenshot's left coordinate. :rtype: int .. attribute:: pixels - List of RGB tuples. + List of row tuples that contain RGB tuples. - :rtype: list[tuple(int, int, int)] + :rtype: list[tuple(tuple(int, int, int), ...)] .. attribute:: pos - The screen shot's coordinates. + The screenshot's coordinates. :rtype: :py:func:`collections.namedtuple()` @@ -267,19 +332,19 @@ Properties .. attribute:: size - The screen shot's size. + The screenshot's size. :rtype: :py:func:`collections.namedtuple()` .. attribute:: top - The screen shot's top coordinate. + The screenshot's top coordinate. :rtype: int .. attribute:: width - The screen shot's width. + The screenshot's width. :rtype: int diff --git a/docs/source/conf.py b/docs/source/conf.py index 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..3dfe19bc 100644 --- a/docs/source/developers.rst +++ b/docs/source/developers.rst @@ -11,11 +11,6 @@ Setup 2. Create you own branch. 3. Be sure to add/update tests and documentation within your patch. -Additionally, you can install `pre-commit `_ to ensure you are doing things well:: - - $ python -m pip install -U --user pre-commit - $ pre-commit install - Testing ======= @@ -23,9 +18,12 @@ Testing Dependency ---------- -You will need `tox `_:: +You will need `pytest `_:: - $ python -m pip install -U --user tox + $ python -m venv venv + $ . venv/bin/activate + $ python -m pip install -U pip + $ python -m pip install -e '.[tests]' How to Test? @@ -33,28 +31,16 @@ How to Test? Launch the test suit:: - $ tox - - # or - $ TOXENV=py37 tox - -This will test MSS and ensure a good code quality. + $ python -m pytest Code Quality ============ -To ensure the code is always well enough using `flake8 `_:: - - $ TOXENV=lint tox - - -Static Type Checking -==================== - -To check type annotation using `mypy `_:: +To ensure the code quality is correct enough:: - $ TOXENV=types tox + $ python -m pip install -e '.[dev]' + $ ./check.sh Documentation @@ -62,4 +48,5 @@ Documentation To build the documentation, simply type:: - $ TOXENV=docs tox + $ python -m pip install -e '.[docs]' + $ sphinx-build -d docs docs/source docs_out --color -W -bhtml diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 137715fd..7bb8157b 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -5,22 +5,22 @@ Examples Basics ====== -One screen shot per monitor ---------------------------- +One screenshot per monitor +-------------------------- :: for filename in sct.save(): print(filename) -Screen shot of the monitor 1 ----------------------------- +Screenshot of the monitor 1 +--------------------------- :: filename = sct.shot() print(filename) -A screen shot to grab them all ------------------------------- +A screenshot to grab them all +----------------------------- :: filename = sct.shot(mon=-1, output='fullscreen.png') @@ -29,10 +29,10 @@ A screen shot to grab them all Callback -------- -Screen shot of the monitor 1 with a callback: +Screenshot of the monitor 1 with a callback: .. literalinclude:: examples/callback.py - :lines: 8- + :lines: 7- Part of the screen @@ -41,7 +41,7 @@ Part of the screen You can capture only a part of the screen: .. literalinclude:: examples/part_of_screen.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -52,7 +52,7 @@ Part of the screen of the 2nd monitor This is an example of capturing some part of the screen of the monitor 2: .. literalinclude:: examples/part_of_screen_monitor_2.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -64,7 +64,7 @@ You can use the same value as you would do with ``PIL.ImageGrab(bbox=tuple(...)) This is an example that uses it, but also using percentage values: .. literalinclude:: examples/from_pil_tuple.py - :lines: 8- + :lines: 7- .. versionadded:: 3.1.0 @@ -77,13 +77,29 @@ You can tweak the PNG compression level (see :py:func:`zlib.compress()` for deta .. versionadded:: 3.2.0 +Get PNG bytes, no file output +----------------------------- + +You can get the bytes of the PNG image: +:: + + with mss.mss() as sct: + # The monitor or screen part to capture + monitor = sct.monitors[1] # or a region + + # Grab the data + sct_img = sct.grab(monitor) + + # Generate the PNG + png = mss.tools.to_png(sct_img.rgb, sct_img.size) + Advanced ======== You can handle data using a custom class: .. literalinclude:: examples/custom_cls_image.py - :lines: 8- + :lines: 7- .. versionadded:: 3.1.0 @@ -94,7 +110,7 @@ You can use the Python Image Library (aka Pillow) to do whatever you want with r This is an example using `frombytes() `_: .. literalinclude:: examples/pil.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -104,7 +120,7 @@ Playing with pixels This is an example using `putdata() `_: .. literalinclude:: examples/pil_pixels.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -116,7 +132,7 @@ You can easily view a HD movie with VLC and see it too in the OpenCV window. And with __no__ lag please. .. literalinclude:: examples/opencv_numpy.py - :lines: 8- + :lines: 7- .. versionadded:: 3.0.0 @@ -129,7 +145,7 @@ Benchmark Simple naive benchmark to compare with `Reading game frames in Python with OpenCV - Python Plays GTA V `_: .. literalinclude:: examples/fps.py - :lines: 9- + :lines: 8- .. versionadded:: 3.0.0 @@ -140,7 +156,7 @@ Performances can be improved by delegating the PNG file creation to a specific w This is a simple example using the :py:mod:`multiprocessing` inspired by the `TensorFlow Object Detection Introduction `_ project: .. literalinclude:: examples/fps_multiprocessing.py - :lines: 9- + :lines: 8- .. versionadded:: 5.0.0 diff --git a/docs/source/examples/callback.py b/docs/source/examples/callback.py index ee79774c..5a93d122 100644 --- a/docs/source/examples/callback.py +++ b/docs/source/examples/callback.py @@ -1,26 +1,21 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. Screenshot of the monitor 1, with callback. """ -import os -import os.path +from pathlib import Path import mss -def on_exists(fname): - # type: (str) -> None - """ - Callback example when we try to overwrite an existing screenshot. - """ - - if os.path.isfile(fname): - newfile = fname + ".old" - print("{} -> {}".format(fname, newfile)) - os.rename(fname, newfile) +def on_exists(fname: str) -> None: + """Callback example when we try to overwrite an existing screenshot.""" + file = Path(fname) + if file.is_file(): + newfile = file.with_name(f"{file.name}.old") + print(f"{fname} → {newfile}") + file.rename(newfile) with mss.mss() as sct: diff --git a/docs/source/examples/custom_cls_image.py b/docs/source/examples/custom_cls_image.py index 4e5d8757..2a1f8102 100644 --- a/docs/source/examples/custom_cls_image.py +++ b/docs/source/examples/custom_cls_image.py @@ -1,26 +1,28 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. Screenshot of the monitor 1, using a custom class to handle the data. """ +from typing import Any + import mss +from mss.models import Monitor +from mss.screenshot import ScreenShot -class SimpleScreenShot: - """ - Define your own custom method to deal with screen shot raw data. +class SimpleScreenShot(ScreenShot): + """Define your own custom method to deal with screenshot raw data. Of course, you can inherit from the ScreenShot class and change or add new methods. """ - def __init__(self, data, monitor, **kwargs): + def __init__(self, data: bytearray, monitor: Monitor, **_: Any) -> None: self.data = data self.monitor = monitor with mss.mss() as sct: - sct.cls_image = SimpleScreenShot # type: ignore + sct.cls_image = SimpleScreenShot image = sct.grab(sct.monitors[1]) # ... diff --git a/docs/source/examples/fps.py b/docs/source/examples/fps.py index e8123780..f9e76134 100644 --- a/docs/source/examples/fps.py +++ b/docs/source/examples/fps.py @@ -1,6 +1,5 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. Simple naive benchmark to compare with: https://pythonprogramming.net/game-frames-open-cv-python-plays-gta-v/ @@ -9,16 +8,13 @@ import time import cv2 -import mss -import numpy +import numpy as np +from PIL import ImageGrab +import mss -def screen_record(): - try: - from PIL import ImageGrab - except ImportError: - return 0 +def screen_record() -> int: # 800x600 windowed mode mon = (0, 40, 800, 640) @@ -27,7 +23,7 @@ def screen_record(): last_time = time.time() while time.time() - last_time < 1: - img = numpy.asarray(ImageGrab.grab(bbox=mon)) + img = np.asarray(ImageGrab.grab(bbox=mon)) fps += 1 cv2.imshow(title, cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) @@ -38,7 +34,7 @@ def screen_record(): return fps -def screen_record_efficient(): +def screen_record_efficient() -> int: # 800x600 windowed mode mon = {"top": 40, "left": 0, "width": 800, "height": 640} @@ -48,7 +44,7 @@ def screen_record_efficient(): last_time = time.time() while time.time() - last_time < 1: - img = numpy.asarray(sct.grab(mon)) + img = np.asarray(sct.grab(mon)) fps += 1 cv2.imshow(title, img) diff --git a/docs/source/examples/fps_multiprocessing.py b/docs/source/examples/fps_multiprocessing.py index d229cb0a..c4a2a38a 100644 --- a/docs/source/examples/fps_multiprocessing.py +++ b/docs/source/examples/fps_multiprocessing.py @@ -1,6 +1,5 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. Example using the multiprocessing module to speed-up screen capture. https://github.com/pythonlessons/TensorFlow-object-detection-tutorial @@ -12,9 +11,7 @@ import mss.tools -def grab(queue): - # type: (Queue) -> None - +def grab(queue: Queue) -> None: rect = {"top": 0, "left": 0, "width": 600, "height": 800} with mss.mss() as sct: @@ -25,9 +22,7 @@ def grab(queue): queue.put(None) -def save(queue): - # type: (Queue) -> None - +def save(queue: Queue) -> None: number = 0 output = "screenshots/file_{}.png" to_png = mss.tools.to_png @@ -43,8 +38,8 @@ def save(queue): if __name__ == "__main__": # The screenshots queue - queue = Queue() # type: Queue + queue: Queue = Queue() - # 2 processes: one for grabing and one for saving PNG files + # 2 processes: one for grabbing and one for saving PNG files Process(target=grab, args=(queue,)).start() Process(target=save, args=(queue,)).start() diff --git a/docs/source/examples/from_pil_tuple.py b/docs/source/examples/from_pil_tuple.py index 0e56cec1..3c5297b9 100644 --- a/docs/source/examples/from_pil_tuple.py +++ b/docs/source/examples/from_pil_tuple.py @@ -1,6 +1,5 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. Use PIL bbox style and percent values. """ @@ -8,7 +7,6 @@ import mss import mss.tools - with mss.mss() as sct: # Use the 1st monitor monitor = sct.monitors[1] @@ -23,7 +21,7 @@ # Grab the picture # Using PIL would be something like: # im = ImageGrab(bbox=bbox) - im = sct.grab(bbox) # type: ignore + im = sct.grab(bbox) # Save it! mss.tools.to_png(im.rgb, im.size, output="screenshot.png") diff --git a/docs/source/examples/linux_display_keyword.py b/docs/source/examples/linux_display_keyword.py index d03341df..bb6c3950 100644 --- a/docs/source/examples/linux_display_keyword.py +++ b/docs/source/examples/linux_display_keyword.py @@ -1,13 +1,11 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. Usage example with a specific display. """ import mss - with mss.mss(display=":0.0") as sct: for filename in sct.save(): print(filename) diff --git a/docs/source/examples/opencv_numpy.py b/docs/source/examples/opencv_numpy.py index 46e05e03..9275de2b 100644 --- a/docs/source/examples/opencv_numpy.py +++ b/docs/source/examples/opencv_numpy.py @@ -1,6 +1,5 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. OpenCV/Numpy example. """ @@ -8,9 +7,9 @@ import time import cv2 -import mss -import numpy +import numpy as np +import mss with mss.mss() as sct: # Part of the screen to capture @@ -20,7 +19,7 @@ last_time = time.time() # Get raw pixels from the screen, save it to a Numpy array - img = numpy.array(sct.grab(monitor)) + img = np.array(sct.grab(monitor)) # Display the picture cv2.imshow("OpenCV/Numpy normal", img) @@ -29,7 +28,7 @@ # cv2.imshow('OpenCV/Numpy grayscale', # cv2.cvtColor(img, cv2.COLOR_BGRA2GRAY)) - print("fps: {}".format(1 / (time.time() - last_time))) + print(f"fps: {1 / (time.time() - last_time)}") # Press "q" to quit if cv2.waitKey(25) & 0xFF == ord("q"): diff --git a/docs/source/examples/part_of_screen.py b/docs/source/examples/part_of_screen.py index e4705a58..5ef341dc 100644 --- a/docs/source/examples/part_of_screen.py +++ b/docs/source/examples/part_of_screen.py @@ -1,6 +1,5 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. Example to capture part of the screen. """ @@ -8,7 +7,6 @@ import mss import mss.tools - with mss.mss() as sct: # The screen part to capture monitor = {"top": 160, "left": 160, "width": 160, "height": 135} diff --git a/docs/source/examples/part_of_screen_monitor_2.py b/docs/source/examples/part_of_screen_monitor_2.py index 9bbc771f..6099f58a 100644 --- a/docs/source/examples/part_of_screen_monitor_2.py +++ b/docs/source/examples/part_of_screen_monitor_2.py @@ -1,6 +1,5 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. Example to capture part of the screen of the monitor 2. """ @@ -8,7 +7,6 @@ import mss import mss.tools - with mss.mss() as sct: # Get information of monitor 2 monitor_number = 2 diff --git a/docs/source/examples/pil.py b/docs/source/examples/pil.py index 4d8e9729..03ff778c 100644 --- a/docs/source/examples/pil.py +++ b/docs/source/examples/pil.py @@ -1,13 +1,12 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. PIL example using frombytes(). """ -import mss from PIL import Image +import mss with mss.mss() as sct: # Get rid of the first, as it represents the "All in One" monitor: @@ -21,6 +20,6 @@ # img = Image.frombytes('RGB', sct_img.size, sct_img.rgb) # And save it! - output = "monitor-{}.png".format(num) + output = f"monitor-{num}.png" img.save(output) print(output) diff --git a/docs/source/examples/pil_pixels.py b/docs/source/examples/pil_pixels.py index 11081746..d1264bc6 100644 --- a/docs/source/examples/pil_pixels.py +++ b/docs/source/examples/pil_pixels.py @@ -1,13 +1,12 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. PIL examples to play with pixels. """ -import mss from PIL import Image +import mss with mss.mss() as sct: # Get a screenshot of the 1st monitor @@ -17,7 +16,7 @@ img = Image.new("RGB", sct_img.size) # Best solution: create a list(tuple(R, G, B), ...) for putdata() - pixels = zip(sct_img.raw[2::4], sct_img.raw[1::4], sct_img.raw[0::4]) + pixels = zip(sct_img.raw[2::4], sct_img.raw[1::4], sct_img.raw[::4]) img.putdata(list(pixels)) # But you can set individual pixels too (slower) diff --git a/docs/source/index.rst b/docs/source/index.rst index 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..4e105a8b 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -9,14 +9,14 @@ So MSS can be used as simply as:: from mss import mss -Or import the good one base on your operating system:: - - # MacOS X - from mss.darwin import MSS as mss +Or import the good one based on your operating system:: # GNU/Linux from mss.linux import MSS as mss + # macOS + from mss.darwin import MSS as mss + # Microsoft Windows from mss.windows import MSS as mss @@ -46,7 +46,7 @@ This is a much better usage, memory efficient:: for _ in range(100): sct.shot() -Also, it is a good thing to save the MSS instance inside an attribute of you class and calling it when needed. +Also, it is a good thing to save the MSS instance inside an attribute of your class and calling it when needed. GNU/Linux @@ -57,10 +57,10 @@ On GNU/Linux, you can specify which display to use (useful for distant screensho with mss(display=":0.0") as sct: # ... -A more specific example to only target GNU/Linux: +A more specific example (only valid on GNU/Linux): .. literalinclude:: examples/linux_display_keyword.py - :lines: 8- + :lines: 9- Command Line @@ -72,6 +72,25 @@ You can use ``mss`` via the CLI:: Or via direct call from Python:: - python -m mss --help + $ python -m mss --help + usage: __main__.py [-h] [-c COORDINATES] [-l {0,1,2,3,4,5,6,7,8,9}] + [-m MONITOR] [-o OUTPUT] [-q] [-v] [--with-cursor] + + options: + -h, --help show this help message and exit + -c COORDINATES, --coordinates COORDINATES + the part of the screen to capture: top, left, width, height + -l {0,1,2,3,4,5,6,7,8,9}, --level {0,1,2,3,4,5,6,7,8,9} + the PNG compression level + -m MONITOR, --monitor MONITOR + the monitor to screenshot + -o OUTPUT, --output OUTPUT + the output file name + --with-cursor include the cursor + -q, --quiet do not print created files + -v, --version show program's version number and exit .. versionadded:: 3.1.1 + +.. versionadded:: 8.0.0 + ``--with-cursor`` to include the cursor in screenshots. diff --git a/docs/source/where.rst b/docs/source/where.rst index 64920ee2..a6307d08 100644 --- a/docs/source/where.rst +++ b/docs/source/where.rst @@ -3,44 +3,33 @@ Who Uses it? ============ This is a non exhaustive list where MSS is integrated or has inspired. -Do not hesistate to `say Hello! `_ if you are using MSS too. - - -AI, Computer Vison -================== +Do not hesitate to `say Hello! `_ if you are using MSS too. +- `Airtest `_, a cross-platform UI automation framework for aames and apps; +- `Automation Framework `_, a Batmans utility; - `DeepEye `_, a deep vision-based software library for autonomous and advanced driver-assistance systems; - `DoomPy `_ (Autonomous Anti-Demonic Combat Algorithms); - `Europilot `_, a self-driving algorithm using Euro Truck Simulator (ETS2); +- `Flexx Python UI toolkit `_; +- `Go Review Partner `_, a tool to help analyse and review your game of go (weiqi, baduk) using strong bots; +- `Gradient Sampler `_, sample blender gradients from anything on the screen; - `gym-mupen64plus `_, an OpenAI Gym environment wrapper for the Mupen64Plus N64 emulator; +- `NativeShot `_ (Mozilla Firefox module); +- `NCTU Scratch and Python, 2017 Spring `_ (Python course); +- `normcap `_, OCR powered screen-capture tool to capture information instead of images; - `Open Source Self Driving Car Initiative `_; +- `OSRS Bot COLOR (OSBC) `_, a lightweight desktop client for controlling and monitoring color-based automation scripts (bots) for OSRS and private server alternatives; +- `Philips Hue Lights Ambiance `_; +- `Pombo `_, a thief recovery software; +- `Python-ImageSearch `_, a wrapper around OpenCV2 and PyAutoGUI to do image searching easily; - `PUBGIS `_, a map generator of your position throughout PUBG gameplay; +- `ScreenCapLibrary `_, a Robot Framework test library for capturing screenshots and video recording; +- `ScreenVivid `_, an open source cross-platform screen recorder for everyone ; - `Self-Driving-Car-3D-Simulator-With-CNN `_; +- `Serpent.AI `_, a Game Agent Framework; - `Star Wars - The Old Republic: Galactic StarFighter `_ parser; +- `Stitch `_, a Python Remote Administration Tool (RAT); - `TensorKart `_, a self-driving MarioKart with TensorFlow; +- `videostream_censor `_, a real time video recording censor ; +- `wow-fishing-bot `_, a fishing bot for World of Warcraft that uses template matching from OpenCV; - `Zelda Bowling AI `_; - -Games -===== - -- `Go Review Partner `_, a tool to help analyse and review your game of go (weiqi, baduk) using strong bots; -- `Serpent.AI `_, a Game Agent Framework; - -Learning -======== - -- `NCTU Scratch and Python, 2017 Spring `_ (Python course); - -Security -======== - -- `Automation Framework `_, a Batmans utility; -- `Pombo `_, a thief recovery software; -- `Stitch `_, a Python Remote Administration Tool (RAT); - -Utilities -========= - -- `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/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..1f3581dd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,188 @@ +[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.2.2.post1", + "mypy==1.15.0", + "ruff==0.11.9", + "twine==6.1.0", +] +docs = [ + "shibuya==2025.4.25", + "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.2.1 ; sys_platform == 'linux' and python_version == '3.13'", + "pytest==8.3.5", + "pytest-cov==6.1.1", + "pytest-rerunfailures==15.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" +addopts = """ + --showlocals + --strict-markers + -r fE + -vvv + --cov=src/mss + --cov-report=term-missing:skip-covered +""" + +[tool.ruff] +exclude = [ + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "venv", +] +line-length = 120 +indent-width = 4 +target-version = "py39" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint] +fixable = ["ALL"] +extend-select = ["ALL"] +ignore = [ + "ANN401", # typing.Any + "C90", # complexity + "COM812", # conflict + "D", # TODO + "ISC001", # conflict + "T201", # `print()` +] + +[tool.ruff.lint.per-file-ignores] +"docs/source/*" = [ + "ERA001", # commented code + "INP001", # file `xxx` is part of an implicit namespace package +] +"src/tests/*" = [ + "FBT001", # boolean-typed positional argument in function definition + "PLR2004", # magic value used in comparison + "S101", # use of `assert` detected + "S602", # `subprocess` call with `shell=True` + "S603", # `subprocess` call + "SLF001", # private member accessed +] 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..ef0faaae --- /dev/null +++ b/src/mss/__init__.py @@ -0,0 +1,27 @@ +"""An ultra fast cross-platform multiple screenshots module in pure python +using ctypes. + +This module is maintained by Mickaël Schoentgen . + +You can always get the latest version of this module at: + https://github.com/BoboTiG/python-mss +If that URL should fail, try contacting the author. +""" + +from mss.exception import ScreenShotError +from mss.factory import mss + +__version__ = "10.1.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/mss/__main__.py b/src/mss/__main__.py similarity index 68% rename from mss/__main__.py rename to src/mss/__main__.py index 939e7ae0..384ad344 100644 --- a/mss/__main__.py +++ b/src/mss/__main__.py @@ -1,27 +1,20 @@ -""" -This is part of the MSS Python's module. -Source: https://github.com/BoboTiG/python-mss +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. """ import os.path import sys from argparse import ArgumentParser -from typing import TYPE_CHECKING - -from . import __version__ -from .exception import ScreenShotError -from .factory import mss -from .tools import to_png -if TYPE_CHECKING: - from typing import List, Optional # noqa +from mss import __version__ +from mss.exception import ScreenShotError +from mss.factory import mss +from mss.tools import to_png -def main(args=None): - # type: (Optional[List[str]]) -> int - """ Main logic. """ - - cli_args = ArgumentParser() +def main(*args: str) -> int: + """Main logic.""" + cli_args = ArgumentParser(prog="mss") cli_args.add_argument( "-c", "--coordinates", @@ -37,12 +30,9 @@ def main(args=None): choices=list(range(10)), help="the PNG compression level", ) - cli_args.add_argument( - "-m", "--monitor", default=0, type=int, help="the monitor to screen shot" - ) - cli_args.add_argument( - "-o", "--output", default="monitor-{mon}.png", help="the output file name" - ) + cli_args.add_argument("-m", "--monitor", default=0, type=int, help="the monitor to screenshot") + cli_args.add_argument("-o", "--output", default="monitor-{mon}.png", help="the output file name") + cli_args.add_argument("--with-cursor", default=False, action="store_true", help="include the cursor") cli_args.add_argument( "-q", "--quiet", @@ -52,7 +42,7 @@ def main(args=None): ) cli_args.add_argument("-v", "--version", action="version", version=__version__) - options = cli_args.parse_args(args) + options = cli_args.parse_args(args or None) kwargs = {"mon": options.monitor, "output": options.output} if options.coordinates: try: @@ -71,7 +61,7 @@ def main(args=None): kwargs["output"] = "sct-{top}x{left}_{width}x{height}.png" try: - with mss() as sct: + with mss(with_cursor=options.with_cursor) as sct: if options.coordinates: output = kwargs["output"].format(**kwargs["mon"]) sct_img = sct.grab(kwargs["mon"]) @@ -84,8 +74,10 @@ def main(args=None): print(os.path.realpath(file_name)) return 0 except ScreenShotError: - return 1 + if options.quiet: + return 1 + raise -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) +if __name__ == "__main__": # pragma: nocover + sys.exit(main()) diff --git a/src/mss/base.py b/src/mss/base.py new file mode 100644 index 00000000..8a7397f5 --- /dev/null +++ b/src/mss/base.py @@ -0,0 +1,261 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from datetime import datetime +from threading import Lock +from typing import TYPE_CHECKING, Any + +from mss.exception import ScreenShotError +from mss.screenshot import ScreenShot +from mss.tools import to_png + +if TYPE_CHECKING: # pragma: nocover + from collections.abc import Callable, Iterator + + from mss.models import Monitor, Monitors + +try: + from datetime import UTC +except ImportError: # pragma: nocover + # Python < 3.11 + from datetime import timezone + + UTC = timezone.utc + +lock = Lock() + +OPAQUE = 255 + + +class MSSBase(metaclass=ABCMeta): + """This class will be overloaded by a system specific one.""" + + __slots__ = {"_monitors", "cls_image", "compression_level", "with_cursor"} + + def __init__( + self, + /, + *, + 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 = [] + + def __enter__(self) -> MSSBase: # noqa:PYI034 + """For the cool call `with MSS() as mss:`.""" + return self + + def __exit__(self, *_: object) -> None: + """For the cool call `with MSS() as mss:`.""" + self.close() + + @abstractmethod + def _cursor_impl(self) -> ScreenShot | None: + """Retrieve all cursor data. Pixels have to be RGB.""" + + @abstractmethod + def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: + """Retrieve all pixels from a monitor. Pixels have to be RGB. + That method has to be run using a threading lock. + """ + + @abstractmethod + def _monitors_impl(self) -> None: + """Get positions of monitors (has to be run using a threading lock). + It must populate self._monitors. + """ + + def close(self) -> None: # noqa:B027 + """Clean-up.""" + + def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: + """Retrieve screen pixels for a given monitor. + + Note: *monitor* can be a tuple like the one PIL.Image.grab() accepts. + + :param monitor: The coordinates and size of the box to capture. + See :meth:`monitors ` for object details. + :return :class:`ScreenShot `. + """ + # Convert PIL bbox style + if isinstance(monitor, tuple): + monitor = { + "left": monitor[0], + "top": monitor[1], + "width": monitor[2] - monitor[0], + "height": monitor[3] - monitor[1], + } + + with lock: + screenshot = self._grab_impl(monitor) + if self.with_cursor and (cursor := self._cursor_impl()): + return self._merge(screenshot, cursor) + return screenshot + + @property + def monitors(self) -> Monitors: + """Get positions of all monitors. + If the monitor has rotation, you have to deal with it + inside this method. + + This method has to fill self._monitors with all information + and use it as a cache: + self._monitors[0] is a dict of all monitors together + self._monitors[N] is a dict of the monitor N (with N > 0) + + Each monitor is a dict with: + { + 'left': the x-coordinate of the upper-left corner, + 'top': the y-coordinate of the upper-left corner, + 'width': the width, + 'height': the height + } + """ + if not self._monitors: + with lock: + self._monitors_impl() + + return self._monitors + + def save( + self, + /, + *, + mon: int = 0, + output: str = "monitor-{mon}.png", + callback: Callable[[str], None] | None = None, + ) -> Iterator[str]: + """Grab a screenshot and save it to a file. + + :param int mon: The monitor to screenshot (default=0). + -1: grab one screenshot of all monitors + 0: grab one screenshot by monitor + N: grab the screenshot of the monitor N + + :param str output: The output filename. + + It can take several keywords to customize the filename: + - `{mon}`: the monitor number + - `{top}`: the screenshot y-coordinate of the upper-left corner + - `{left}`: the screenshot x-coordinate of the upper-left corner + - `{width}`: the screenshot's width + - `{height}`: the screenshot's height + - `{date}`: the current date using the default formatter + + As it is using the `format()` function, you can specify + formatting options like `{date:%Y-%m-%s}`. + + :param callable callback: Callback called before saving the + screenshot to a file. Take the `output` argument as parameter. + + :return generator: Created file(s). + """ + monitors = self.monitors + if not monitors: + msg = "No monitor found." + raise ScreenShotError(msg) + + if mon == 0: + # One screenshot by monitor + for idx, monitor in enumerate(monitors[1:], 1): + fname = output.format(mon=idx, date=datetime.now(UTC) if "{date" in output else None, **monitor) + if callable(callback): + callback(fname) + sct = self.grab(monitor) + to_png(sct.rgb, sct.size, level=self.compression_level, output=fname) + yield fname + else: + # A screenshot of all monitors together or + # a screenshot of the monitor N. + mon = 0 if mon == -1 else mon + try: + monitor = monitors[mon] + except IndexError as exc: + msg = f"Monitor {mon!r} does not exist." + raise ScreenShotError(msg) from exc + + output = output.format(mon=mon, date=datetime.now(UTC) if "{date" in output else None, **monitor) + if callable(callback): + callback(output) + sct = self.grab(monitor) + to_png(sct.rgb, sct.size, level=self.compression_level, output=output) + yield output + + def shot(self, /, **kwargs: Any) -> str: + """Helper to save the screenshot of the 1st monitor, by default. + You can pass the same arguments as for ``save``. + """ + kwargs["mon"] = kwargs.get("mon", 1) + return next(self.save(**kwargs)) + + @staticmethod + def _merge(screenshot: ScreenShot, cursor: ScreenShot, /) -> ScreenShot: + """Create composite image by blending screenshot and mouse cursor.""" + (cx, cy), (cw, ch) = cursor.pos, cursor.size + (x, y), (w, h) = screenshot.pos, screenshot.size + + cx2, cy2 = cx + cw, cy + ch + x2, y2 = x + w, y + h + + overlap = cx < x2 and cx2 > x and cy < y2 and cy2 > y + if not overlap: + return screenshot + + screen_raw = screenshot.raw + cursor_raw = cursor.raw + + cy, cy2 = (cy - y) * 4, (cy2 - y2) * 4 + cx, cx2 = (cx - x) * 4, (cx2 - x2) * 4 + start_count_y = -cy if cy < 0 else 0 + start_count_x = -cx if cx < 0 else 0 + stop_count_y = ch * 4 - max(cy2, 0) + stop_count_x = cw * 4 - max(cx2, 0) + rgb = range(3) + + for count_y in range(start_count_y, stop_count_y, 4): + pos_s = (count_y + cy) * w + cx + pos_c = count_y * cw + + for count_x in range(start_count_x, stop_count_x, 4): + spos = pos_s + count_x + cpos = pos_c + count_x + alpha = cursor_raw[cpos + 3] + + if not alpha: + continue + + if alpha == OPAQUE: + screen_raw[spos : spos + 3] = cursor_raw[cpos : cpos + 3] + else: + alpha2 = alpha / 255 + for i in rgb: + screen_raw[spos + i] = int(cursor_raw[cpos + i] * alpha2 + screen_raw[spos + i] * (1 - alpha2)) + + return screenshot + + @staticmethod + def _cfactory( + attr: Any, + func: str, + argtypes: list[Any], + restype: Any, + /, + errcheck: Callable | None = None, + ) -> None: + """Factory to create a ctypes function and automatically manage errors.""" + meth = getattr(attr, func) + meth.argtypes = argtypes + meth.restype = restype + if errcheck: + meth.errcheck = errcheck diff --git a/src/mss/darwin.py b/src/mss/darwin.py new file mode 100644 index 00000000..f001398b --- /dev/null +++ b/src/mss/darwin.py @@ -0,0 +1,217 @@ +"""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",) + +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..b0793e8c --- /dev/null +++ b/src/mss/factory.py @@ -0,0 +1,40 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import platform +from typing import Any + +from mss.base import MSSBase +from mss.exception import ScreenShotError + + +def mss(**kwargs: Any) -> MSSBase: + """Factory returning a proper MSS class instance. + + It detects the platform we are running on + and chooses the most adapted mss_class to take + screenshots. + + It then proxies its arguments to the class for + instantiation. + """ + os_ = platform.system().lower() + + if os_ == "darwin": + from mss import darwin + + return darwin.MSS(**kwargs) + + if os_ == "linux": + from mss import linux + + return linux.MSS(**kwargs) + + if os_ == "windows": + from mss import windows + + return windows.MSS(**kwargs) + + msg = f"System {os_!r} not (yet?) implemented." + raise ScreenShotError(msg) diff --git a/src/mss/linux.py b/src/mss/linux.py new file mode 100644 index 00000000..009b4234 --- /dev/null +++ b/src/mss/linux.py @@ -0,0 +1,481 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +import os +from contextlib import suppress +from ctypes import ( + CFUNCTYPE, + POINTER, + Structure, + byref, + c_char_p, + c_int, + c_int32, + c_long, + c_short, + c_ubyte, + c_uint, + c_uint32, + c_ulong, + c_ushort, + c_void_p, + cast, + cdll, + create_string_buffer, +) +from ctypes.util import find_library +from threading import current_thread, local +from typing import TYPE_CHECKING, Any + +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",) + + +PLAINMASK = 0x00FFFFFF +ZPIXMAP = 2 +BITS_PER_PIXELS_32 = 32 +SUPPORTED_BITS_PER_PIXELS = { + BITS_PER_PIXELS_32, +} + + +class Display(Structure): + """Structure that serves as the connection to the X server + and that contains all the information about that X server. + https://github.com/garrybodsworth/pyxlib-ctypes/blob/master/pyxlib/xlib.py#L831. + """ + + +class XErrorEvent(Structure): + """XErrorEvent to debug eventual errors. + https://tronche.com/gui/x/xlib/event-handling/protocol-errors/default-handlers.html. + """ + + _fields_ = ( + ("type", c_int), + ("display", POINTER(Display)), # Display the event was read from + ("serial", c_ulong), # serial number of failed request + ("error_code", c_ubyte), # error code of failed request + ("request_code", c_ubyte), # major op-code of failed request + ("minor_code", c_ubyte), # minor op-code of failed request + ("resourceid", c_void_p), # resource ID + ) + + +class XFixesCursorImage(Structure): + """Cursor structure. + /usr/include/X11/extensions/Xfixes.h + https://github.com/freedesktop/xorg-libXfixes/blob/libXfixes-6.0.0/include/X11/extensions/Xfixes.h#L96. + """ + + _fields_ = ( + ("x", c_short), + ("y", c_short), + ("width", c_ushort), + ("height", c_ushort), + ("xhot", c_ushort), + ("yhot", c_ushort), + ("cursor_serial", c_ulong), + ("pixels", POINTER(c_ulong)), + ("atom", c_ulong), + ("name", c_char_p), + ) + + +class XImage(Structure): + """Description of an image as it exists in the client's memory. + https://tronche.com/gui/x/xlib/graphics/images.html. + """ + + _fields_ = ( + ("width", c_int), # size of image + ("height", c_int), # size of image + ("xoffset", c_int), # number of pixels offset in X direction + ("format", c_int), # XYBitmap, XYPixmap, ZPixmap + ("data", c_void_p), # pointer to image data + ("byte_order", c_int), # data byte order, LSBFirst, MSBFirst + ("bitmap_unit", c_int), # quant. of scanline 8, 16, 32 + ("bitmap_bit_order", c_int), # LSBFirst, MSBFirst + ("bitmap_pad", c_int), # 8, 16, 32 either XY or ZPixmap + ("depth", c_int), # depth of image + ("bytes_per_line", c_int), # 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 + ) + + +class XRRCrtcInfo(Structure): + """Structure that contains CRTC information. + https://gitlab.freedesktop.org/xorg/lib/libxrandr/-/blob/master/include/X11/extensions/Xrandr.h#L360. + """ + + _fields_ = ( + ("timestamp", c_ulong), + ("x", c_int), + ("y", c_int), + ("width", c_uint), + ("height", c_uint), + ("mode", c_long), + ("rotation", c_int), + ("noutput", c_int), + ("outputs", POINTER(c_long)), + ("rotations", c_ushort), + ("npossible", c_int), + ("possible", POINTER(c_long)), + ) + + +class XRRModeInfo(Structure): + """https://gitlab.freedesktop.org/xorg/lib/libxrandr/-/blob/master/include/X11/extensions/Xrandr.h#L248.""" + + +class XRRScreenResources(Structure): + """Structure that contains arrays of XIDs that point to the + available outputs and associated CRTCs. + https://gitlab.freedesktop.org/xorg/lib/libxrandr/-/blob/master/include/X11/extensions/Xrandr.h#L265. + """ + + _fields_ = ( + ("timestamp", c_ulong), + ("configTimestamp", c_ulong), + ("ncrtc", c_int), + ("crtcs", POINTER(c_long)), + ("noutput", c_int), + ("outputs", POINTER(c_long)), + ("nmode", c_int), + ("modes", POINTER(XRRModeInfo)), + ) + + +class XWindowAttributes(Structure): + """Attributes for the specified window.""" + + _fields_ = ( + ("x", c_int32), # location of window + ("y", c_int32), # location of window + ("width", c_int32), # width of window + ("height", c_int32), # height of window + ("border_width", c_int32), # border width of window + ("depth", c_int32), # depth of window + ("visual", c_ulong), # the associated visual structure + ("root", c_ulong), # root of screen containing window + ("class", c_int32), # InputOutput, InputOnly + ("bit_gravity", c_int32), # one of bit gravity values + ("win_gravity", c_int32), # one of the window gravity values + ("backing_store", c_int32), # NotUseful, WhenMapped, Always + ("backing_planes", c_ulong), # planes to be preserved if possible + ("backing_pixel", c_ulong), # value to be used when restoring planes + ("save_under", c_int32), # boolean, should bits under be saved? + ("colormap", c_ulong), # color map to be associated with window + ("mapinstalled", c_uint32), # boolean, is color map currently installed + ("map_state", c_uint32), # IsUnmapped, IsUnviewable, IsViewable + ("all_event_masks", c_ulong), # set of events all people have interest in + ("your_event_mask", c_ulong), # my event mask + ("do_not_propagate_mask", c_ulong), # set of events that should not propagate + ("override_redirect", c_int32), # boolean value for override-redirect + ("screen", c_ulong), # back pointer to correct screen + ) + + +_ERROR = {} +_X11 = find_library("X11") +_XFIXES = find_library("Xfixes") +_XRANDR = find_library("Xrandr") + + +@CFUNCTYPE(c_int, POINTER(Display), POINTER(XErrorEvent)) +def _error_handler(display: Display, event: XErrorEvent) -> int: + """Specifies the program's supplied error handler.""" + # Get the specific error message + xlib = cdll.LoadLibrary(_X11) # type: ignore[arg-type] + get_error = xlib.XGetErrorText + get_error.argtypes = [POINTER(Display), c_int, c_char_p, c_int] + get_error.restype = c_void_p + + evt = event.contents + error = create_string_buffer(1024) + get_error(display, evt.error_code, error, len(error)) + + _ERROR[current_thread()] = { + "error": error.value.decode("utf-8"), + "error_code": evt.error_code, + "minor_code": evt.minor_code, + "request_code": evt.request_code, + "serial": evt.serial, + "type": evt.type, + } + + return 0 + + +def _validate(retval: int, func: Any, args: tuple[Any, Any], /) -> tuple[Any, Any]: + """Validate the returned value of a C function call.""" + thread = current_thread() + if retval != 0 and thread not in _ERROR: + return args + + details = _ERROR.pop(thread, {}) + msg = f"{func.__name__}() failed" + raise ScreenShotError(msg, details=details) + + +# C functions that will be initialised later. +# See https://tronche.com/gui/x/xlib/function-index.html for details. +# +# Available attr: xfixes, xlib, xrandr. +# +# Note: keep it sorted by cfunction. +CFUNCTIONS: CFunctions = { + # Syntax: cfunction: (attr, argtypes, restype) + "XCloseDisplay": ("xlib", [POINTER(Display)], c_void_p), + "XDefaultRootWindow": ("xlib", [POINTER(Display)], POINTER(XWindowAttributes)), + "XDestroyImage": ("xlib", [POINTER(XImage)], c_void_p), + "XFixesGetCursorImage": ("xfixes", [POINTER(Display)], POINTER(XFixesCursorImage)), + "XGetImage": ( + "xlib", + [POINTER(Display), POINTER(Display), c_int, c_int, c_uint, c_uint, c_ulong, c_int], + POINTER(XImage), + ), + "XGetWindowAttributes": ("xlib", [POINTER(Display), POINTER(XWindowAttributes), POINTER(XWindowAttributes)], c_int), + "XOpenDisplay": ("xlib", [c_char_p], POINTER(Display)), + "XQueryExtension": ("xlib", [POINTER(Display), c_char_p, POINTER(c_int), POINTER(c_int), POINTER(c_int)], c_uint), + "XRRFreeCrtcInfo": ("xrandr", [POINTER(XRRCrtcInfo)], c_void_p), + "XRRFreeScreenResources": ("xrandr", [POINTER(XRRScreenResources)], c_void_p), + "XRRGetCrtcInfo": ("xrandr", [POINTER(Display), POINTER(XRRScreenResources), c_long], POINTER(XRRCrtcInfo)), + "XRRGetScreenResources": ("xrandr", [POINTER(Display), POINTER(Display)], POINTER(XRRScreenResources)), + "XRRGetScreenResourcesCurrent": ("xrandr", [POINTER(Display), POINTER(Display)], POINTER(XRRScreenResources)), + "XSetErrorHandler": ("xlib", [c_void_p], c_void_p), +} + + +class MSS(MSSBase): + """Multiple ScreenShots implementation for GNU/Linux. + It uses intensively the Xlib and its Xrandr extension. + """ + + __slots__ = {"_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.root = self.xlib.XDefaultRootWindow(self._handles.display) + + # Fix for XRRGetScreenResources and XGetImage: + # expected LP_Display instance instead of LP_XWindowAttributes + self._handles.drawable = cast(self._handles.root, POINTER(Display)) + + def close(self) -> None: + # 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 + cfactory(attrs[attr], func, argtypes, restype, errcheck=errcheck) + + def _monitors_impl(self) -> None: + """Get positions of monitors. It will populate self._monitors.""" + display = self._handles.display + int_ = int + xrandr = self.xrandr + + # All monitors + gwa = XWindowAttributes() + self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa)) + self._monitors.append( + {"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)}, + ) + + # Each monitor + # A simple benchmark calling 10 times those 2 functions: + # XRRGetScreenResources(): 0.1755971429956844 s + # XRRGetScreenResourcesCurrent(): 0.0039125580078689 s + # The second is faster by a factor of 44! So try to use it first. + try: + mon = xrandr.XRRGetScreenResourcesCurrent(display, self._handles.drawable).contents + except AttributeError: + mon = xrandr.XRRGetScreenResources(display, self._handles.drawable).contents + + crtcs = mon.crtcs + for idx in range(mon.ncrtc): + crtc = xrandr.XRRGetCrtcInfo(display, mon, crtcs[idx]).contents + if crtc.noutput == 0: + xrandr.XRRFreeCrtcInfo(crtc) + continue + + self._monitors.append( + { + "left": int_(crtc.x), + "top": int_(crtc.y), + "width": int_(crtc.width), + "height": int_(crtc.height), + }, + ) + xrandr.XRRFreeCrtcInfo(crtc) + xrandr.XRRFreeScreenResources(mon) + + def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: + """Retrieve all pixels from a monitor. Pixels have to be RGB.""" + ximage = self.xlib.XGetImage( + self._handles.display, + self._handles.drawable, + monitor["left"], + monitor["top"], + monitor["width"], + monitor["height"], + PLAINMASK, + ZPIXMAP, + ) + + try: + bits_per_pixel = ximage.contents.bits_per_pixel + if bits_per_pixel not in SUPPORTED_BITS_PER_PIXELS: + msg = f"[XImage] bits per pixel value not (yet?) implemented: {bits_per_pixel}." + raise ScreenShotError(msg) + + raw_data = cast( + ximage.contents.data, + POINTER(c_ubyte * monitor["height"] * monitor["width"] * 4), + ) + data = bytearray(raw_data.contents) + finally: + # Free + self.xlib.XDestroyImage(ximage) + + return self.cls_image(data, monitor) + + def _cursor_impl(self) -> ScreenShot: + """Retrieve all cursor data. Pixels have to be RGB.""" + # Read data of cursor/mouse-pointer + ximage = self.xfixes.XFixesGetCursorImage(self._handles.display) + if not (ximage and ximage.contents): + msg = "Cannot read XFixesGetCursorImage()" + raise ScreenShotError(msg) + + cursor_img: XFixesCursorImage = ximage.contents + region = { + "left": cursor_img.x - cursor_img.xhot, + "top": cursor_img.y - cursor_img.yhot, + "width": cursor_img.width, + "height": cursor_img.height, + } + + raw_data = cast(cursor_img.pixels, POINTER(c_ulong * region["height"] * region["width"])) + raw = bytearray(raw_data.contents) + + data = bytearray(region["height"] * region["width"] * 4) + data[3::4] = raw[3::8] + data[2::4] = raw[2::8] + data[1::4] = raw[1::8] + data[::4] = raw[::8] + + return self.cls_image(data, region) diff --git a/src/mss/models.py b/src/mss/models.py new file mode 100644 index 00000000..665a41bc --- /dev/null +++ b/src/mss/models.py @@ -0,0 +1,23 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from typing import Any, NamedTuple + +Monitor = dict[str, int] +Monitors = list[Monitor] + +Pixel = tuple[int, int, int] +Pixels = list[tuple[Pixel, ...]] + +CFunctions = dict[str, tuple[str, list[Any], Any]] + + +class Pos(NamedTuple): + left: int + top: int + + +class Size(NamedTuple): + width: int + height: int diff --git a/src/mss/py.typed b/src/mss/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/mss/screenshot.py b/src/mss/screenshot.py new file mode 100644 index 00000000..5bcf654b --- /dev/null +++ b/src/mss/screenshot.py @@ -0,0 +1,125 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from mss.exception import ScreenShotError +from mss.models import Monitor, Pixel, Pixels, Pos, Size + +if TYPE_CHECKING: # pragma: nocover + from collections.abc import Iterator + + +class ScreenShot: + """Screenshot object. + + .. note:: + + A better name would have been *Image*, but to prevent collisions + with PIL.Image, it has been decided to use *ScreenShot*. + """ + + __slots__ = {"__pixels", "__rgb", "pos", "raw", "size"} + + def __init__(self, data: bytearray, monitor: Monitor, /, *, size: Size | None = None) -> None: + self.__pixels: Pixels | None = None + self.__rgb: bytes | None = None + + #: Bytearray of the raw BGRA pixels retrieved by ctypes + #: OS independent implementations. + self.raw = data + + #: NamedTuple of the screenshot coordinates. + self.pos = Pos(monitor["left"], monitor["top"]) + + #: NamedTuple of the screenshot size. + self.size = Size(monitor["width"], monitor["height"]) if size is None else size + + def __repr__(self) -> str: + return f"<{type(self).__name__} pos={self.left},{self.top} size={self.width}x{self.height}>" + + @property + def __array_interface__(self) -> dict[str, Any]: + """Numpy array interface support. + It uses raw data in BGRA form. + + See https://docs.scipy.org/doc/numpy/reference/arrays.interface.html + """ + return { + "version": 3, + "shape": (self.height, self.width, 4), + "typestr": "|u1", + "data": self.raw, + } + + @classmethod + def from_size(cls: type[ScreenShot], data: bytearray, width: int, height: int, /) -> ScreenShot: + """Instantiate a new class given only screenshot's data and size.""" + monitor = {"left": 0, "top": 0, "width": width, "height": height} + return cls(data, monitor) + + @property + def bgra(self) -> bytes: + """BGRA values from the BGRA raw pixels.""" + return bytes(self.raw) + + @property + def height(self) -> int: + """Convenient accessor to the height size.""" + return self.size.height + + @property + def left(self) -> int: + """Convenient accessor to the left position.""" + return self.pos.left + + @property + def pixels(self) -> Pixels: + """:return list: RGB tuples.""" + if not self.__pixels: + rgb_tuples: Iterator[Pixel] = zip(self.raw[2::4], self.raw[1::4], self.raw[::4]) + self.__pixels = list(zip(*[iter(rgb_tuples)] * self.width)) + + return self.__pixels + + @property + def rgb(self) -> bytes: + """Compute RGB values from the BGRA raw pixels. + + :return bytes: RGB pixels. + """ + if not self.__rgb: + rgb = bytearray(self.height * self.width * 3) + raw = self.raw + rgb[::3] = raw[2::4] + rgb[1::3] = raw[1::4] + rgb[2::3] = raw[::4] + self.__rgb = bytes(rgb) + + return self.__rgb + + @property + def top(self) -> int: + """Convenient accessor to the top position.""" + return self.pos.top + + @property + def width(self) -> int: + """Convenient accessor to the width size.""" + return self.size.width + + def pixel(self, coord_x: int, coord_y: int) -> Pixel: + """Returns the pixel value at a given position. + + :param int coord_x: The x coordinate. + :param int coord_y: The y coordinate. + :return tuple: The pixel value as (R, G, B). + """ + try: + return self.pixels[coord_y][coord_x] + except IndexError as exc: + msg = f"Pixel location ({coord_x}, {coord_y}) is out of range." + raise ScreenShotError(msg) from exc diff --git a/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..d5e2bb78 --- /dev/null +++ b/src/mss/windows.py @@ -0,0 +1,250 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +import ctypes +import sys +from ctypes import POINTER, WINFUNCTYPE, Structure, c_int, c_void_p +from ctypes.wintypes import ( + BOOL, + DOUBLE, + DWORD, + HBITMAP, + HDC, + HGDIOBJ, + HWND, + INT, + LONG, + LPARAM, + LPRECT, + RECT, + UINT, + WORD, +) +from threading import local +from typing import TYPE_CHECKING, Any + +from mss.base import MSSBase +from mss.exception import ScreenShotError + +if TYPE_CHECKING: # pragma: nocover + from mss.models import CFunctions, Monitor + from mss.screenshot import ScreenShot + +__all__ = ("MSS",) + + +CAPTUREBLT = 0x40000000 +DIB_RGB_COLORS = 0 +SRCCOPY = 0x00CC0020 + + +class BITMAPINFOHEADER(Structure): + """Information about the dimensions and color format of a DIB.""" + + _fields_ = ( + ("biSize", DWORD), + ("biWidth", LONG), + ("biHeight", LONG), + ("biPlanes", WORD), + ("biBitCount", WORD), + ("biCompression", DWORD), + ("biSizeImage", DWORD), + ("biXPelsPerMeter", LONG), + ("biYPelsPerMeter", LONG), + ("biClrUsed", DWORD), + ("biClrImportant", DWORD), + ) + + +class BITMAPINFO(Structure): + """Structure that defines the dimensions and color information for a DIB.""" + + _fields_ = (("bmiHeader", BITMAPINFOHEADER), ("bmiColors", DWORD * 3)) + + +MONITORNUMPROC = WINFUNCTYPE(INT, DWORD, DWORD, POINTER(RECT), DOUBLE) + + +# C functions that will be initialised later. +# +# Available attr: gdi32, user32. +# +# Note: keep it sorted by cfunction. +CFUNCTIONS: CFunctions = { + # Syntax: cfunction: (attr, argtypes, restype) + "BitBlt": ("gdi32", [HDC, INT, INT, INT, INT, HDC, INT, INT, DWORD], BOOL), + "CreateCompatibleBitmap": ("gdi32", [HDC, INT, INT], HBITMAP), + "CreateCompatibleDC": ("gdi32", [HDC], HDC), + "DeleteDC": ("gdi32", [HDC], HDC), + "DeleteObject": ("gdi32", [HGDIOBJ], INT), + "EnumDisplayMonitors": ("user32", [HDC, c_void_p, MONITORNUMPROC, LPARAM], BOOL), + "GetDeviceCaps": ("gdi32", [HWND, INT], INT), + "GetDIBits": ("gdi32", [HDC, HBITMAP, UINT, UINT, c_void_p, POINTER(BITMAPINFO), UINT], BOOL), + "GetSystemMetrics": ("user32", [INT], INT), + "GetWindowDC": ("user32", [HWND], HDC), + "ReleaseDC": ("user32", [HWND, HDC], c_int), + "SelectObject": ("gdi32", [HDC, HGDIOBJ], HGDIOBJ), +} + + +class MSS(MSSBase): + """Multiple ScreenShots implementation for Microsoft Windows.""" + + __slots__ = {"_handles", "gdi32", "user32"} + + def __init__(self, /, **kwargs: Any) -> None: + """Windows initialisations.""" + super().__init__(**kwargs) + + self.user32 = ctypes.WinDLL("user32") + self.gdi32 = ctypes.WinDLL("gdi32") + self._set_cfunctions() + self._set_dpi_awareness() + + # Available thread-specific variables + self._handles = local() + self._handles.region_width_height = (0, 0) + self._handles.bmp = None + self._handles.srcdc = self.user32.GetWindowDC(0) + self._handles.memdc = self.gdi32.CreateCompatibleDC(self._handles.srcdc) + + bmi = BITMAPINFO() + bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biPlanes = 1 # Always 1 + bmi.bmiHeader.biBitCount = 32 # See grab.__doc__ [2] + bmi.bmiHeader.biCompression = 0 # 0 = BI_RGB (no compression) + bmi.bmiHeader.biClrUsed = 0 # See grab.__doc__ [3] + bmi.bmiHeader.biClrImportant = 0 # See grab.__doc__ [3] + self._handles.bmi = bmi + + def close(self) -> None: + # Clean-up + if self._handles.bmp: + self.gdi32.DeleteObject(self._handles.bmp) + self._handles.bmp = None + + if self._handles.memdc: + self.gdi32.DeleteDC(self._handles.memdc) + self._handles.memdc = None + + if self._handles.srcdc: + self.user32.ReleaseDC(0, self._handles.srcdc) + self._handles.srcdc = None + + def _set_cfunctions(self) -> None: + """Set all ctypes functions and attach them to attributes.""" + cfactory = self._cfactory + attrs = { + "gdi32": self.gdi32, + "user32": self.user32, + } + for func, (attr, argtypes, restype) in CFUNCTIONS.items(): + cfactory(attrs[attr], func, argtypes, restype) + + def _set_dpi_awareness(self) -> None: + """Set DPI awareness to capture full screen on Hi-DPI monitors.""" + version = sys.getwindowsversion()[:2] + if version >= (6, 3): + # Windows 8.1+ + # Here 2 = PROCESS_PER_MONITOR_DPI_AWARE, which means: + # per monitor DPI aware. This app checks for the DPI when it is + # created and adjusts the scale factor whenever the DPI changes. + # These applications are not automatically scaled by the system. + ctypes.windll.shcore.SetProcessDpiAwareness(2) + elif (6, 0) <= version < (6, 3): + # Windows Vista, 7, 8, and Server 2012 + self.user32.SetProcessDPIAware() + + def _monitors_impl(self) -> None: + """Get positions of monitors. It will populate self._monitors.""" + int_ = int + user32 = self.user32 + get_system_metrics = user32.GetSystemMetrics + + # All monitors + self._monitors.append( + { + "left": int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN + "top": int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN + "width": int_(get_system_metrics(78)), # SM_CXVIRTUALSCREEN + "height": int_(get_system_metrics(79)), # SM_CYVIRTUALSCREEN + }, + ) + + # Each monitor + def _callback(_monitor: int, _data: HDC, rect: LPRECT, _dc: LPARAM) -> int: + """Callback for monitorenumproc() function, it will return + a RECT with appropriate values. + """ + rct = rect.contents + self._monitors.append( + { + "left": int_(rct.left), + "top": int_(rct.top), + "width": int_(rct.right) - int_(rct.left), + "height": int_(rct.bottom) - int_(rct.top), + }, + ) + return 1 + + callback = MONITORNUMPROC(_callback) + user32.EnumDisplayMonitors(0, 0, callback, 0) + + def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: + """Retrieve all pixels from a monitor. Pixels have to be RGB. + + In the code, there are a few interesting things: + + [1] bmi.bmiHeader.biHeight = -height + + A bottom-up DIB is specified by setting the height to a + positive number, while a top-down DIB is specified by + setting the height to a negative number. + https://msdn.microsoft.com/en-us/library/ms787796.aspx + https://msdn.microsoft.com/en-us/library/dd144879%28v=vs.85%29.aspx + + + [2] bmi.bmiHeader.biBitCount = 32 + image_data = create_string_buffer(height * width * 4) + + We grab the image in RGBX mode, so that each word is 32bit + and we have no striding. + Inspired by https://github.com/zoofIO/flexx + + + [3] bmi.bmiHeader.biClrUsed = 0 + bmi.bmiHeader.biClrImportant = 0 + + When biClrUsed and biClrImportant are set to zero, there + is "no" color table, so we can read the pixels of the bitmap + retrieved by gdi32.GetDIBits() as a sequence of RGB values. + Thanks to http://stackoverflow.com/a/3688682 + """ + srcdc, memdc = self._handles.srcdc, self._handles.memdc + gdi = self.gdi32 + width, height = monitor["width"], monitor["height"] + + if self._handles.region_width_height != (width, height): + self._handles.region_width_height = (width, height) + self._handles.bmi.bmiHeader.biWidth = width + self._handles.bmi.bmiHeader.biHeight = -height # Why minus? [1] + self._handles.data = ctypes.create_string_buffer(width * height * 4) # [2] + if self._handles.bmp: + gdi.DeleteObject(self._handles.bmp) + self._handles.bmp = gdi.CreateCompatibleBitmap(srcdc, width, height) + gdi.SelectObject(memdc, self._handles.bmp) + + gdi.BitBlt(memdc, 0, 0, width, height, srcdc, monitor["left"], monitor["top"], SRCCOPY | CAPTUREBLT) + bits = gdi.GetDIBits(memdc, self._handles.bmp, 0, height, self._handles.data, self._handles.bmi, DIB_RGB_COLORS) + if bits != height: + msg = "gdi32.GetDIBits() failed." + raise ScreenShotError(msg) + + return self.cls_image(bytearray(self._handles.data), monitor) + + def _cursor_impl(self) -> ScreenShot | None: + """Retrieve all cursor data. Pixels have to be RGB.""" + return None diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/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..97928b78 --- /dev/null +++ b/src/tests/conftest.py @@ -0,0 +1,47 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from collections.abc import Generator +from hashlib import sha256 +from pathlib import Path +from zipfile import ZipFile + +import pytest + + +@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(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 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..10c9cfed --- /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. +""" + +import os +from typing import Any + +from mss import mss +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() -> None: + with mss(display=os.getenv("DISPLAY")) 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..81c6e1ea --- /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. +""" + +import os + +from mss import mss + + +def test_get_monitors() -> None: + with mss(display=os.getenv("DISPLAY")) as sct: + assert sct.monitors + + +def test_keys_aio() -> None: + with mss(display=os.getenv("DISPLAY")) 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() -> None: + with mss(display=os.getenv("DISPLAY")) 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() -> None: + with mss(display=os.getenv("DISPLAY")) 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..486823c3 --- /dev/null +++ b/src/tests/test_get_pixels.py @@ -0,0 +1,48 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import itertools +import os + +import pytest + +from mss import mss +from mss.base import ScreenShot +from mss.exception import ScreenShotError + + +def test_grab_monitor() -> None: + with mss(display=os.getenv("DISPLAY")) 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() -> None: + with mss(display=os.getenv("DISPLAY")) 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..87d53eea --- /dev/null +++ b/src/tests/test_gnu_linux.py @@ -0,0 +1,185 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import platform +from collections.abc import Generator +from unittest.mock import Mock, patch + +import pytest + +import mss +import mss.linux +from mss.base import MSSBase +from mss.exception import ScreenShotError + +pyvirtualdisplay = pytest.importorskip("pyvirtualdisplay") + +PYPY = platform.python_implementation() == "PyPy" + +WIDTH = 200 +HEIGHT = 200 +DEPTH = 24 + + +@pytest.fixture +def display() -> Generator: + with pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=DEPTH) as vdisplay: + yield vdisplay.new_display_var + + +@pytest.mark.skipif(PYPY, reason="Failure on PyPy") +def test_factory_systems(monkeypatch: pytest.MonkeyPatch) -> 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() 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(): + pass + monkeypatch.undo() + + # Windows + monkeypatch.setattr(platform, "system", lambda: "wInDoWs") + with pytest.raises(ImportError, match="cannot import name 'WINFUNCTYPE'"), mss.mss(): + pass + + +def test_arg_display(display: str, monkeypatch: pytest.MonkeyPatch) -> None: + # Good value + with mss.mss(display=display): + pass + + # Bad `display` (missing ":" in front of the number) + with pytest.raises(ScreenShotError), mss.mss(display="0"): + pass + + # Invalid `display` that is not trivially distinguishable. + with pytest.raises(ScreenShotError), mss.mss(display=":INVALID"): + pass + + # No `DISPLAY` in envars + monkeypatch.delenv("DISPLAY") + with pytest.raises(ScreenShotError), mss.mss(): + pass + + +@pytest.mark.skipif(PYPY, reason="Failure on PyPy") +def test_bad_display_structure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mss.linux, "Display", lambda: None) + with pytest.raises(TypeError), mss.mss(): + pass + + +@patch("mss.linux._X11", new=None) +def test_no_xlib_library() -> None: + with pytest.raises(ScreenShotError), mss.mss(): + pass + + +@patch("mss.linux._XRANDR", new=None) +def test_no_xrandr_extension() -> None: + with pytest.raises(ScreenShotError), mss.mss(): + pass + + +@patch("mss.linux.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): + pass + + +def test_unsupported_depth() -> None: + with ( + pyvirtualdisplay.Display(size=(WIDTH, HEIGHT), color_depth=8) as vdisplay, + pytest.raises(ScreenShotError), + mss.mss(display=vdisplay.new_display_var) as sct, + ): + sct.grab(sct.monitors[1]) + + +def test_region_out_of_monitor_bounds(display: str) -> None: + monitor = {"left": -30, "top": 0, "width": WIDTH, "height": HEIGHT} + + assert not mss.linux._ERROR + + with mss.mss(display=display) as sct: + with pytest.raises(ScreenShotError) as exc: + sct.grab(monitor) + + assert str(exc.value) + + details = exc.value.details + assert details + assert isinstance(details, dict) + assert isinstance(details["error"], str) + assert not mss.linux._ERROR + + assert not mss.linux._ERROR + + +def test__is_extension_enabled_unknown_name(display: str) -> None: + with mss.mss(display=display) as sct: + assert isinstance(sct, mss.linux.MSS) # For Mypy + assert not sct._is_extension_enabled("NOEXT") + + +def test_missing_fast_function_for_monitor_details_retrieval(display: str) -> None: + with mss.mss(display=display) as sct: + assert isinstance(sct, mss.linux.MSS) # For Mypy + assert hasattr(sct.xrandr, "XRRGetScreenResourcesCurrent") + screenshot_with_fast_fn = sct.grab(sct.monitors[1]) + + assert set(screenshot_with_fast_fn.rgb) == {0} + + with mss.mss(display=display) as sct: + assert isinstance(sct, mss.linux.MSS) # For Mypy + assert hasattr(sct.xrandr, "XRRGetScreenResourcesCurrent") + del sct.xrandr.XRRGetScreenResourcesCurrent + screenshot_with_slow_fn = sct.grab(sct.monitors[1]) + + assert set(screenshot_with_slow_fn.rgb) == {0} + + +def test_with_cursor(display: str) -> None: + with mss.mss(display=display) as sct: + assert not hasattr(sct, "xfixes") + assert not sct.with_cursor + screenshot_without_cursor = sct.grab(sct.monitors[1]) + + # 1 color: black + assert set(screenshot_without_cursor.rgb) == {0} + + with mss.mss(display=display, with_cursor=True) as sct: + assert hasattr(sct, "xfixes") + 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._XFIXES", new=None) +def test_with_cursor_but_not_xfixes_extension_found(display: str) -> None: + with mss.mss(display=display, with_cursor=True) as sct: + assert not hasattr(sct, "xfixes") + assert not sct.with_cursor + + +def test_with_cursor_failure(display: str) -> None: + with mss.mss(display=display, with_cursor=True) as sct: + assert isinstance(sct, mss.linux.MSS) # For Mypy + with ( + patch.object(sct.xfixes, "XFixesGetCursorImage", return_value=None), + pytest.raises(ScreenShotError), + ): + sct.grab(sct.monitors[1]) diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py new file mode 100644 index 00000000..294ccc80 --- /dev/null +++ b/src/tests/test_implementation.py @@ -0,0 +1,263 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from __future__ import annotations + +import os +import os.path +import platform +import sys +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import Mock, patch + +import pytest + +import mss +import mss.tools +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 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() -> None: + with mss.mss(display=os.getenv("DISPLAY")) as sct, pytest.raises(ScreenShotError): + sct.shot(mon=222) + + +def test_repr() -> None: + box = {"top": 0, "left": 0, "width": 10, "height": 10} + expected_box = {"top": 0, "left": 0, "width": 10, "height": 10} + with mss.mss(display=os.getenv("DISPLAY")) as sct: + img = sct.grab(box) + ref = ScreenShot(bytearray(b"42"), expected_box) + assert repr(img) == repr(ref) + + +def test_factory(monkeypatch: pytest.MonkeyPatch) -> None: + # Current system + with mss.mss() as sct: + assert isinstance(sct, MSSBase) + + # Unknown + monkeypatch.setattr(platform, "system", lambda: "Chuck Norris") + with pytest.raises(ScreenShotError) as exc: + mss.mss() + monkeypatch.undo() + + error = exc.value.args[0] + assert error == "System 'chuck norris' not (yet?) implemented." + + +@patch.object(sys, "argv", new=[]) # Prevent side effects while testing +@pytest.mark.parametrize("with_cursor", [False, True]) +def test_entry_point(with_cursor: bool, capsys: pytest.CaptureFixture) -> None: + def main(*args: str, ret: int = 0) -> None: + if with_cursor: + args = (*args, "--with-cursor") + assert entry_point(*args) == ret + + # No arguments + main() + captured = capsys.readouterr() + for mon, line in enumerate(captured.out.splitlines(), 1): + filename = Path(f"monitor-{mon}.png") + assert line.endswith(filename.name) + assert filename.is_file() + filename.unlink() + + file = Path("monitor-1.png") + for opt in ("-m", "--monitor"): + main(opt, "1") + captured = capsys.readouterr() + assert captured.out.endswith(f"{file.name}\n") + assert filename.is_file() + filename.unlink() + + for opts in zip(["-m 1", "--monitor=1"], ["-q", "--quiet"]): + main(*opts) + captured = capsys.readouterr() + assert not captured.out + assert filename.is_file() + filename.unlink() + + fmt = "sct-{mon}-{width}x{height}.png" + for opt in ("-o", "--out"): + main(opt, fmt) + captured = capsys.readouterr() + with mss.mss(display=os.getenv("DISPLAY")) as sct: + for mon, (monitor, line) in enumerate(zip(sct.monitors[1:], captured.out.splitlines()), 1): + filename = Path(fmt.format(mon=mon, **monitor)) + assert line.endswith(filename.name) + assert filename.is_file() + filename.unlink() + + fmt = "sct_{mon}-{date:%Y-%m-%d}.png" + for opt in ("-o", "--out"): + main("-m 1", opt, fmt) + filename = Path(fmt.format(mon=1, date=datetime.now(tz=UTC))) + captured = capsys.readouterr() + assert captured.out.endswith(f"{filename}\n") + assert filename.is_file() + filename.unlink() + + coordinates = "2,12,40,67" + filename = Path("sct-2x12_40x67.png") + for opt in ("-c", "--coordinates"): + main(opt, coordinates) + captured = capsys.readouterr() + assert captured.out.endswith(f"{filename}\n") + assert filename.is_file() + filename.unlink() + + coordinates = "2,12,40" + for opt in ("-c", "--coordinates"): + main(opt, coordinates, ret=2) + captured = capsys.readouterr() + assert captured.out == "Coordinates syntax: top, left, width, height\n" + + +@patch.object(sys, "argv", new=[]) # Prevent side effects while testing +@patch("mss.base.MSSBase.monitors", new=[]) +@pytest.mark.parametrize("quiet", [False, True]) +def test_entry_point_error(quiet: bool, capsys: pytest.CaptureFixture) -> None: + def main(*args: str) -> int: + if quiet: + args = (*args, "--quiet") + return entry_point(*args) + + if quiet: + assert main() == 1 + captured = capsys.readouterr() + assert not captured.out + assert not captured.err + else: + with pytest.raises(ScreenShotError): + main() + + +def test_entry_point_with_no_argument(capsys: pytest.CaptureFixture) -> None: + # Make sure to fail if arguments are not handled + with ( + patch("mss.factory.mss", new=Mock(side_effect=RuntimeError("Boom!"))), + patch.object(sys, "argv", ["mss", "--help"]), + pytest.raises(SystemExit) as exc, + ): + entry_point() + assert exc.value.code == 0 + + captured = capsys.readouterr() + assert not captured.err + assert "usage: mss" in captured.out + + +def test_grab_with_tuple() -> None: + left = 100 + top = 100 + right = 500 + lower = 500 + width = right - left # 400px width + height = lower - top # 400px height + + with mss.mss(display=os.getenv("DISPLAY")) 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() -> None: + with mss.mss(display=os.getenv("DISPLAY")) 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() -> None: + """Regression test for issue #169.""" + import threading + import time + + def record(check: dict) -> None: + """Record for one second.""" + start_time = time.time() + while time.time() - start_time < 1: + with mss.mss() as sct: + sct.grab(sct.monitors[1]) + + 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..24884c4b --- /dev/null +++ b/src/tests/test_issue_220.py @@ -0,0 +1,59 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +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() -> None: + region = {"top": 370, "left": 1090, "width": 80, "height": 390} + with mss.mss() as sct: + sct.grab(region) + + +def create_top_level_win(master: tkinter.Tk) -> None: # type: ignore[name-defined] + top_level_win = tkinter.Toplevel(master) + + take_screenshot_btn = tkinter.Button(top_level_win, text="Take screenshot", command=take_screenshot) + take_screenshot_btn.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) -> None: # type: ignore[name-defined] + btn = tkinter.Button(root, text="Open TopLevel", command=lambda: create_top_level_win(root)) + 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..f27e7f77 --- /dev/null +++ b/src/tests/test_leaks.py @@ -0,0 +1,127 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import os +import platform +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. + """ + import subprocess + + cmd = f"lsof -U | grep {PID}" + output = subprocess.check_output(cmd, shell=True) + return len(output.splitlines()) + + +def get_handles() -> int: + """Windows: a way to get the GDI handles count. + It will be used to check the handles count is not growing, showing resource leaks. + """ + import ctypes + + PROCESS_QUERY_INFORMATION = 0x400 # noqa:N806 + GR_GDIOBJECTS = 0 # noqa:N806 + h = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, 0, PID) + return ctypes.windll.user32.GetGuiResources(h, GR_GDIOBJECTS) + + +@pytest.fixture +def monitor_func() -> Callable[[], int]: + """OS specific function to check resources in use.""" + return get_opened_socket if OS == "linux" else get_handles + + +def bound_instance_without_cm() -> None: + # Will always leak + sct = mss.mss() + sct.shot() + + +def bound_instance_without_cm_but_use_close() -> None: + sct = mss.mss() + sct.shot() + sct.close() + # Calling .close() twice should be possible + sct.close() + + +def unbound_instance_without_cm() -> None: + # Will always leak + mss.mss().shot() + + +def with_context_manager() -> None: + with mss.mss() as sct: + sct.shot() + + +def regression_issue_128() -> None: + """Regression test for issue #128: areas overlap.""" + with mss.mss() as sct: + area1 = {"top": 50, "left": 7, "width": 400, "height": 320, "mon": 1} + sct.grab(area1) + area2 = {"top": 200, "left": 200, "width": 320, "height": 320, "mon": 1} + sct.grab(area2) + + +def regression_issue_135() -> None: + """Regression test for issue #135: multiple areas.""" + with mss.mss() as sct: + bounding_box_notes = {"top": 0, "left": 0, "width": 100, "height": 100} + sct.grab(bounding_box_notes) + bounding_box_test = {"top": 220, "left": 220, "width": 100, "height": 100} + sct.grab(bounding_box_test) + bounding_box_score = {"top": 110, "left": 110, "width": 100, "height": 100} + sct.grab(bounding_box_score) + + +def regression_issue_210() -> None: + """Regression test for issue #210: multiple X servers.""" + pyvirtualdisplay = pytest.importorskip("pyvirtualdisplay") + + with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.mss(): + pass + + with pyvirtualdisplay.Display(size=(1920, 1080), color_depth=24), mss.mss(): + 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]) -> None: + """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 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..9597206c --- /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. +""" + +import os.path +from datetime import datetime +from pathlib import Path + +import pytest + +from mss import mss + +try: + from datetime import UTC +except ImportError: + # Python < 3.11 + from datetime import timezone + + UTC = timezone.utc + + +def test_at_least_2_monitors() -> None: + with mss(display=os.getenv("DISPLAY")) as sct: + assert list(sct.save(mon=0)) + + +def test_files_exist() -> None: + with mss(display=os.getenv("DISPLAY")) 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() -> None: + def on_exists(fname: str) -> None: + file = Path(fname) + if Path(file).is_file(): + file.rename(f"{file.name}.old") + + with mss(display=os.getenv("DISPLAY")) as sct: + filename = sct.shot(mon=0, output="mon0.png", callback=on_exists) + assert Path(filename).is_file() + + filename = sct.shot(output="mon1.png", callback=on_exists) + assert Path(filename).is_file() + + +def test_output_format_simple() -> None: + with mss(display=os.getenv("DISPLAY")) as sct: + filename = sct.shot(mon=1, output="mon-{mon}.png") + assert filename == "mon-1.png" + assert Path(filename).is_file() + + +def test_output_format_positions_and_sizes() -> None: + fmt = "sct-{top}x{left}_{width}x{height}.png" + with mss(display=os.getenv("DISPLAY")) 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() -> None: + fmt = "sct_{mon}-{date}.png" + with mss(display=os.getenv("DISPLAY")) 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() -> None: + fmt = "sct_{date:%Y-%m-%d}.png" + with mss(display=os.getenv("DISPLAY")) as sct: + filename = sct.shot(mon=1, output=fmt) + assert filename == fmt.format(date=datetime.now(tz=UTC)) + assert Path(filename).is_file() diff --git a/src/tests/test_setup.py b/src/tests/test_setup.py new file mode 100644 index 00000000..d4788677 --- /dev/null +++ b/src/tests/test_setup.py @@ -0,0 +1,130 @@ +"""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".split() +WHEEL = "python -m build --wheel".split() +CHECK = "twine check --strict".split() + + +def test_sdist() -> None: + output = check_output(SDIST, stderr=STDOUT, text=True) + file = f"mss-{__version__}.tar.gz" + assert f"Successfully built {file}" in output + assert "warning" not in output.lower() + + check_call([*CHECK, f"dist/{file}"]) + + with tarfile.open(f"dist/{file}", mode="r:gz") as fh: + files = sorted(fh.getnames()) + + assert files == [ + f"mss-{__version__}/.gitignore", + f"mss-{__version__}/CHANGELOG.md", + f"mss-{__version__}/CHANGES.md", + f"mss-{__version__}/CONTRIBUTORS.md", + f"mss-{__version__}/LICENSE.txt", + f"mss-{__version__}/PKG-INFO", + f"mss-{__version__}/README.md", + f"mss-{__version__}/docs/source/api.rst", + f"mss-{__version__}/docs/source/conf.py", + f"mss-{__version__}/docs/source/developers.rst", + f"mss-{__version__}/docs/source/examples.rst", + f"mss-{__version__}/docs/source/examples/callback.py", + f"mss-{__version__}/docs/source/examples/custom_cls_image.py", + f"mss-{__version__}/docs/source/examples/fps.py", + f"mss-{__version__}/docs/source/examples/fps_multiprocessing.py", + f"mss-{__version__}/docs/source/examples/from_pil_tuple.py", + f"mss-{__version__}/docs/source/examples/linux_display_keyword.py", + f"mss-{__version__}/docs/source/examples/opencv_numpy.py", + f"mss-{__version__}/docs/source/examples/part_of_screen.py", + f"mss-{__version__}/docs/source/examples/part_of_screen_monitor_2.py", + f"mss-{__version__}/docs/source/examples/pil.py", + f"mss-{__version__}/docs/source/examples/pil_pixels.py", + f"mss-{__version__}/docs/source/index.rst", + f"mss-{__version__}/docs/source/installation.rst", + f"mss-{__version__}/docs/source/support.rst", + f"mss-{__version__}/docs/source/usage.rst", + f"mss-{__version__}/docs/source/where.rst", + f"mss-{__version__}/pyproject.toml", + f"mss-{__version__}/src/mss/__init__.py", + f"mss-{__version__}/src/mss/__main__.py", + f"mss-{__version__}/src/mss/base.py", + f"mss-{__version__}/src/mss/darwin.py", + f"mss-{__version__}/src/mss/exception.py", + f"mss-{__version__}/src/mss/factory.py", + f"mss-{__version__}/src/mss/linux.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/third_party/__init__.py", + f"mss-{__version__}/src/tests/third_party/test_numpy.py", + f"mss-{__version__}/src/tests/third_party/test_pil.py", + ] + + +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.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..a1494833 --- /dev/null +++ b/src/tests/test_tools.py @@ -0,0 +1,71 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import hashlib +import os.path +import zlib +from pathlib import Path + +import pytest + +from mss import mss +from mss.tools import to_png + +WIDTH = 10 +HEIGHT = 10 +MD5SUM = "ee1b645cc989cbfc48e613b395a929d3d79a922b77b9b38e46647ff6f74acef5" + + +def test_bad_compression_level() -> None: + with mss(compression_level=42, display=os.getenv("DISPLAY")) as sct, pytest.raises(zlib.error): + sct.shot() + + +def test_compression_level() -> None: + data = b"rgb" * WIDTH * HEIGHT + output = Path(f"{WIDTH}x{HEIGHT}.png") + + with mss(display=os.getenv("DISPLAY")) as sct: + to_png(data, (WIDTH, HEIGHT), level=sct.compression_level, output=output) + + assert hashlib.sha256(output.read_bytes()).hexdigest() == MD5SUM + + +@pytest.mark.parametrize( + ("level", "checksum"), + [ + (0, "547191069e78eef1c5899f12c256dd549b1338e67c5cd26a7cbd1fc5a71b83aa"), + (1, "841665ec73b641dfcafff5130b497f5c692ca121caeb06b1d002ad3de5c77321"), + (2, "b11107163207f68f36294deb3f8e6b6a5a11399a532917bdd59d1d5f1117d4d0"), + (3, "31278bad8c1c077c715ac4f3b497694a323a71a87c5ff8bdc7600a36bd8d8c96"), + (4, "8f7237e1394d9ddc71fcb1fa4a2c2953087562ef6eac85d32d8154b61b287fb0"), + (5, "83a55f161bad2d511b222dcd32059c9adf32c3238b65f9aa576f19bc0a6c8fec"), + (6, "ee1b645cc989cbfc48e613b395a929d3d79a922b77b9b38e46647ff6f74acef5"), + (7, "85f8d1b01cef926c111b194229bd6c01e2a65b18b4dd902293698e6de8f4e9ac"), + (8, "85f8d1b01cef926c111b194229bd6c01e2a65b18b4dd902293698e6de8f4e9ac"), + (9, "85f8d1b01cef926c111b194229bd6c01e2a65b18b4dd902293698e6de8f4e9ac"), + ], +) +def test_compression_levels(level: int, checksum: str) -> None: + data = b"rgb" * WIDTH * HEIGHT + raw = to_png(data, (WIDTH, HEIGHT), level=level) + assert isinstance(raw, bytes) + sha256 = hashlib.sha256(raw).hexdigest() + assert sha256 == checksum + + +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 hashlib.sha256(output.read_bytes()).hexdigest() == MD5SUM + + +def test_output_raw_bytes() -> None: + data = b"rgb" * WIDTH * HEIGHT + raw = to_png(data, (WIDTH, HEIGHT)) + assert isinstance(raw, bytes) + assert hashlib.sha256(raw).hexdigest() == MD5SUM 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/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..6d5cf286 --- /dev/null +++ b/src/tests/third_party/test_numpy.py @@ -0,0 +1,19 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import os +import os.path + +import pytest + +from mss import mss + +np = pytest.importorskip("numpy", reason="Numpy module not available.") + + +def test_numpy() -> None: + box = {"top": 0, "left": 0, "width": 10, "height": 10} + with mss(display=os.getenv("DISPLAY")) as sct: + img = np.array(sct.grab(box)) + assert len(img) == 10 diff --git a/src/tests/third_party/test_pil.py b/src/tests/third_party/test_pil.py new file mode 100644 index 00000000..a3194485 --- /dev/null +++ b/src/tests/third_party/test_pil.py @@ -0,0 +1,68 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +import itertools +import os +import os.path +from pathlib import Path + +import pytest + +from mss import mss + +Image = pytest.importorskip("PIL.Image", reason="PIL module not available.") + + +def test_pil() -> None: + width, height = 16, 16 + box = {"top": 0, "left": 0, "width": width, "height": height} + with mss(display=os.getenv("DISPLAY")) 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() -> None: + width, height = 16, 16 + box = {"top": 0, "left": 0, "width": width, "height": height} + with mss(display=os.getenv("DISPLAY")) 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() -> None: + width, height = 10, 10 + box = {"top": 0, "left": 0, "width": width, "height": height} + with mss(display=os.getenv("DISPLAY")) 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/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')"