├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .travis.yml.bak ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── Vagrantfile ├── clean.py ├── doc ├── gen │ ├── python3_-m_pyvirtualdisplay.examples.lowres.png │ ├── python3_-m_pyvirtualdisplay.examples.lowres.txt │ ├── python3_-m_pyvirtualdisplay.examples.nested.png │ ├── python3_-m_pyvirtualdisplay.examples.nested.txt │ ├── python3_-m_pyvirtualdisplay.examples.screenshot.txt │ ├── python3_-m_pyvirtualdisplay.examples.threadsafe.txt │ ├── python3_-m_pyvirtualdisplay.examples.vncserver.txt │ ├── vncviewer_localhost:5904.png │ ├── vncviewer_localhost:5904.txt │ ├── xmessage.png │ ├── xmessage1.png │ └── xmessage2.png ├── generate-doc.py └── hierarchy.dot ├── format-code.sh ├── lint.sh ├── pytest.ini ├── pyvirtualdisplay ├── __init__.py ├── about.py ├── abstractdisplay.py ├── display.py ├── examples │ ├── __init__.py │ ├── headless.py │ ├── lowres.py │ ├── nested.py │ ├── screenshot.py │ ├── threadsafe.py │ └── vncserver.py ├── py.typed ├── smartdisplay.py ├── util.py ├── xauth.py ├── xephyr.py ├── xvfb.py └── xvnc.py ├── requirements-doc.txt ├── requirements-test.txt ├── setup.py ├── tests ├── slowgui.py ├── test_core.py ├── test_examples.py ├── test_race.py ├── test_smart.py ├── test_smart2.py ├── test_smart_thread.py ├── test_threads.py ├── test_with.py ├── test_xauth.py ├── test_xvnc.py ├── tutil.py └── vagrant │ ├── Vagrantfile.debian10.rb │ ├── Vagrantfile.debian11.rb │ ├── Vagrantfile.ubuntu1804.rb │ ├── Vagrantfile.ubuntu2004.rb │ ├── Vagrantfile.ubuntu2204.rb │ ├── debian10.sh │ ├── debian11.sh │ ├── osx.sh │ ├── ubuntu1804.sh │ ├── ubuntu2004.sh │ ├── ubuntu2204.sh │ └── vagrant_boxes.py └── tox.ini /.gitattributes: -------------------------------------------------------------------------------- 1 | pyvirtualdisplay/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # For more information see: 2 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | schedule: 8 | # * is a special character in YAML so you have to quote this string 9 | - cron: '30 5 1 */3 *' 10 | push: 11 | pull_request: 12 | jobs: 13 | build: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: 19 | - "ubuntu-22.04" 20 | - "macos-11" 21 | - "macos-12" 22 | - "macos-13" 23 | python-version: 24 | - "3.9" 25 | - "3.10" 26 | - "3.11" 27 | - "3.12" 28 | include: 29 | - python-version: "3.6" 30 | os: ubuntu-20.04 31 | - python-version: "3.7" 32 | os: ubuntu-20.04 33 | - python-version: "3.8" 34 | os: ubuntu-20.04 35 | - python-version: "3.9" 36 | os: ubuntu-20.04 37 | - python-version: "3.10" 38 | os: ubuntu-20.04 39 | - python-version: "3.11" 40 | os: ubuntu-20.04 41 | - python-version: "3.12" 42 | os: ubuntu-20.04 43 | steps: 44 | - uses: actions/checkout@v3 45 | - name: Set up Python ${{ matrix.python-version }} 46 | uses: actions/setup-python@v4 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | - name: Install Linux dependencies 50 | if: startsWith(matrix.os, 'ubuntu') 51 | run: | 52 | sudo apt-get update 53 | sudo apt-get install -y tigervnc-standalone-server tightvncserver xserver-xephyr gnumeric x11-utils 54 | - name: Install MacOS dependencies 55 | if: startsWith(matrix.os, 'macos') 56 | run: | 57 | brew install --cask xquartz 58 | # https://docs.github.com/en/actions/learn-github-actions/workflow-commands-for-github-actions#adding-a-system-path 59 | echo "/opt/X11/bin" >> $GITHUB_PATH 60 | # https://github.com/ponty/PyVirtualDisplay/issues/42 61 | # mkdir /tmp/.X11-unix 62 | # sudo chmod 1777 /tmp/.X11-unix 63 | # sudo chown root /tmp/.X11-unix 64 | - name: Xvfb -help 65 | run: | 66 | Xvfb -help 67 | - name: pip install 68 | run: | 69 | python -m pip install . 70 | pip install -r requirements-test.txt 71 | - name: Test with pytest 72 | run: | 73 | cd tests 74 | pytest -v . 75 | # - name: Lint 76 | # if: matrix.os == 'ubuntu-20.04' 77 | # run: | 78 | # ./lint.sh 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[c|o] 2 | *.egg 3 | *.egg-info 4 | /build 5 | /dist 6 | /nbproject/ 7 | pip-log.txt 8 | /.project 9 | /.pydevproject 10 | /include 11 | /lib 12 | /lib64 13 | /bin 14 | /virtualenv 15 | 16 | *.bak 17 | */build/* 18 | */_build/* 19 | */_build/latex/* 20 | 21 | *.class 22 | #*.png 23 | .version 24 | nosetests.xml 25 | 26 | .* 27 | !.git* 28 | !.travis* 29 | !.coveragerc 30 | 31 | 32 | /distribute_setup.py 33 | 34 | 35 | sloccount.sc 36 | *.prefs 37 | 38 | MANIFEST 39 | 40 | *.log 41 | 42 | Vagrantfile.osx.* 43 | Vagrantfile.win.* -------------------------------------------------------------------------------- /.travis.yml.bak: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | matrix: 4 | include: 5 | - name: 3.6_trusty 6 | python: 3.6 7 | dist: trusty 8 | 9 | - name: 3.6_xenial 10 | python: 3.6 11 | dist: xenial 12 | 13 | - name: 3.7_xenial 14 | python: 3.7 15 | dist: xenial 16 | 17 | - name: 3.8_xenial 18 | python: 3.8 19 | dist: xenial 20 | 21 | - name: 3.7_bionic 22 | python: 3.7 23 | dist: bionic 24 | 25 | - name: 3.8_bionic 26 | python: 3.8 27 | dist: bionic 28 | 29 | - name: 3.8_focal 30 | python: 3.8 31 | dist: focal 32 | 33 | - name: 3.9_focal 34 | python: 3.9 35 | dist: focal 36 | 37 | - name: 3.9_focal_no_displayfd 38 | python: 3.9 39 | dist: focal 40 | env: 41 | - PYVIRTUALDISPLAY_DISPLAYFD=0 42 | 43 | - name: "Python 3.7 on macOS" 44 | os: osx 45 | osx_image: xcode11.2 # Python 3.7.4 running on macOS 10.14.4 46 | language: shell # 'language: python' is an error on Travis CI macOS 47 | env: PATH=/Users/travis/Library/Python/3.7/bin:$PATH PIPUSER=--user 48 | 49 | # - name: "Python 3.8 on Windows" 50 | # os: windows # Windows 10.0.17134 N/A Build 17134 51 | # language: shell # 'language: python' is an error on Travis CI Windows 52 | # before_install: 53 | # - choco install python --version 3.8 54 | # - python -m pip install --upgrade pip 55 | # env: PATH=/c/Python38:/c/Python38/Scripts:$PATH 56 | 57 | addons: 58 | apt: 59 | packages: 60 | - xvfb 61 | - xserver-xephyr 62 | - scrot 63 | - gnumeric 64 | - x11-utils 65 | - x11-apps 66 | - xfonts-base 67 | 68 | # vnc4server is later renamed to tigervnc-standalone-server 69 | install: 70 | - if [ ${TRAVIS_OS_NAME} == "linux" ]; then sudo apt-get install -y vnc4server || true; fi 71 | - if [ ${TRAVIS_OS_NAME} == "linux" ]; then sudo apt-get install -y tigervnc-standalone-server || true; fi 72 | - PYTHON=python3 73 | - if [ ${TRAVIS_OS_NAME} == "windows" ]; then PYTHON=python; fi 74 | - $PYTHON -m pip install $PIPUSER --upgrade -r requirements-test.txt 75 | - $PYTHON -m pip install $PIPUSER --upgrade . 76 | # http://blog.tigerteufel.de/?p=476 77 | - if [ ${TRAVIS_OS_NAME} == "osx" ]; then mkdir /tmp/.X11-unix;sudo chmod 1777 /tmp/.X11-unix;sudo chown root /tmp/.X11-unix/; fi 78 | 79 | script: 80 | - cd tests 81 | - $PYTHON -m pytest -v . 82 | 83 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, ponty 2 | All rights reserved. 3 | 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 17 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 18 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE* 2 | recursive-include tests *.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pyvirtualdisplay is a python wrapper for [Xvfb][1], [Xephyr][2] and [Xvnc][3] programs. 2 | They all use the X Window System (not Windows, not macOS) 3 | The selected program should be installed first so that it can be started without a path, 4 | otherwise pyvirtualdisplay will not find the program. 5 | 6 | Links: 7 | * home: https://github.com/ponty/pyvirtualdisplay 8 | * PYPI: https://pypi.python.org/pypi/pyvirtualdisplay 9 | 10 | ![workflow](https://github.com/ponty/pyvirtualdisplay/actions/workflows/main.yml/badge.svg) 11 | 12 | Features: 13 | - python wrapper 14 | - supported python versions: 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12 15 | - back-ends: [Xvfb][1], [Xephyr][2] and [Xvnc][3] 16 | 17 | Possible applications: 18 | * headless run 19 | * GUI testing 20 | * automatic GUI screenshot 21 | 22 | Installation 23 | ============ 24 | 25 | install the program: 26 | 27 | ```console 28 | $ python3 -m pip install pyvirtualdisplay 29 | ``` 30 | 31 | optional: [Pillow][pillow] should be installed for ``smartdisplay`` submodule: 32 | 33 | ```console 34 | $ python3 -m pip install pillow 35 | ``` 36 | 37 | optional: [EasyProcess][EasyProcess] should be installed for some examples: 38 | 39 | ```console 40 | $ python3 -m pip install EasyProcess 41 | ``` 42 | optional: xmessage and gnumeric should be installed for some examples. 43 | 44 | On Ubuntu 22.04: 45 | ```console 46 | $ sudo apt install x11-utils gnumeric 47 | ``` 48 | 49 | If you get this error message on Linux then your Pillow version is old. 50 | ``` 51 | ImportError: ImageGrab is macOS and Windows only 52 | ``` 53 | 54 | Install all dependencies and backends on Ubuntu 22.04: 55 | 56 | ```console 57 | $ sudo apt-get install xvfb xserver-xephyr tigervnc-standalone-server x11-utils gnumeric 58 | $ python3 -m pip install pyvirtualdisplay pillow EasyProcess 59 | ``` 60 | 61 | Usage 62 | ===== 63 | 64 | Controlling the display with context manager: 65 | 66 | ```py 67 | from pyvirtualdisplay import Display 68 | with Display() as disp: 69 | # display is active 70 | print(disp.is_alive()) # True 71 | # display is stopped 72 | print(disp.is_alive()) # False 73 | ``` 74 | 75 | Controlling the display with `start()` and `stop()` methods (not recommended): 76 | 77 | ```py 78 | from pyvirtualdisplay import Display 79 | disp = Display() 80 | disp.start() 81 | # display is active 82 | disp.stop() 83 | # display is stopped 84 | ``` 85 | 86 | After Xvfb display is activated "DISPLAY" environment variable is set for Xvfb. 87 | (e.g. `os.environ["DISPLAY"] = :1`) 88 | After Xvfb display is stopped `start()` and `stop()` are not allowed to be called again, "DISPLAY" environment variable is restored to its original value. 89 | 90 | 91 | Selecting Xvfb backend: 92 | 93 | ```py 94 | disp=Display() 95 | # or 96 | disp=Display(visible=False) 97 | # or 98 | disp=Display(backend="xvfb") 99 | ``` 100 | 101 | Selecting Xephyr backend: 102 | ```py 103 | disp=Display(visible=True) 104 | # or 105 | disp=Display(backend="xephyr") 106 | ``` 107 | 108 | Selecting Xvnc backend: 109 | ```py 110 | disp=Display(backend="xvnc") 111 | ``` 112 | 113 | Setting display size: 114 | 115 | ```py 116 | disp=Display(size=(100, 60)) 117 | ``` 118 | 119 | Setting display color depth: 120 | 121 | ```py 122 | disp=Display(color_depth=24) 123 | ``` 124 | 125 | Headless run 126 | ------------ 127 | 128 | A messagebox is displayed on a hidden display. 129 | 130 | ```py 131 | # pyvirtualdisplay/examples/headless.py 132 | 133 | "Start Xvfb server. Open xmessage window." 134 | 135 | from easyprocess import EasyProcess 136 | 137 | from pyvirtualdisplay import Display 138 | 139 | with Display(visible=False, size=(100, 60)) as disp: 140 | with EasyProcess(["xmessage", "hello"]) as proc: 141 | proc.wait() 142 | 143 | ``` 144 | Run it: 145 | ```console 146 | $ python3 -m pyvirtualdisplay.examples.headless 147 | ``` 148 | 149 | If `visible=True` then a nested Xephyr window opens and the GUI can be controlled. 150 | 151 | vncserver 152 | --------- 153 | 154 | The same as headless example, but it can be controlled with a VNC client. 155 | 156 | ```py 157 | # pyvirtualdisplay/examples/vncserver.py 158 | 159 | "Start virtual VNC server. Connect with: vncviewer localhost:5904" 160 | 161 | from easyprocess import EasyProcess 162 | 163 | from pyvirtualdisplay import Display 164 | 165 | with Display(backend="xvnc", size=(100, 60), rfbport=5904) as disp: 166 | with EasyProcess(["xmessage", "hello"]) as proc: 167 | proc.wait() 168 | 169 | ``` 170 | 171 | Run it: 172 | ```console 173 | $ python3 -m pyvirtualdisplay.examples.vncserver 174 | ``` 175 | 176 | 177 | Check it with vncviewer: 178 | ```console 179 | $ vncviewer localhost:5904 180 | ``` 181 | 182 | ![](doc/gen/vncviewer_localhost:5904.png) 183 | 184 | 185 | GUI Test 186 | -------- 187 | 188 | ```py 189 | # pyvirtualdisplay/examples/lowres.py 190 | 191 | "Testing gnumeric on low resolution." 192 | from easyprocess import EasyProcess 193 | 194 | from pyvirtualdisplay import Display 195 | 196 | # start Xephyr 197 | with Display(visible=True, size=(320, 240)) as disp: 198 | # start Gnumeric 199 | with EasyProcess(["gnumeric"]) as proc: 200 | proc.wait() 201 | 202 | ``` 203 | 204 | 205 | Run it: 206 | ```console 207 | $ python3 -m pyvirtualdisplay.examples.lowres 208 | ``` 209 | 210 | Image: 211 | 212 | ![](doc/gen/python3_-m_pyvirtualdisplay.examples.lowres.png) 213 | 214 | 215 | Screenshot 216 | ---------- 217 | 218 | ```py 219 | # pyvirtualdisplay/examples/screenshot.py 220 | 221 | "Create screenshot of xmessage in background using 'smartdisplay' submodule" 222 | from easyprocess import EasyProcess 223 | 224 | from pyvirtualdisplay.smartdisplay import SmartDisplay 225 | 226 | # 'SmartDisplay' instead of 'Display' 227 | # It has 'waitgrab()' method. 228 | # It has more dependencies than Display. 229 | with SmartDisplay() as disp: 230 | with EasyProcess(["xmessage", "hello"]): 231 | # wait until something is displayed on the virtual display (polling method) 232 | # and then take a fullscreen screenshot 233 | # and then crop it. Background is black. 234 | img = disp.waitgrab() 235 | img.save("xmessage.png") 236 | 237 | ``` 238 | 239 | 240 | Run it: 241 | ```console 242 | $ python3 -m pyvirtualdisplay.examples.screenshot 243 | ``` 244 | 245 | Image: 246 | 247 | ![](doc/gen/xmessage.png) 248 | 249 | 250 | Nested Xephyr 251 | ------------- 252 | 253 | ```py 254 | # pyvirtualdisplay/examples/nested.py 255 | 256 | "Nested Xephyr servers" 257 | from easyprocess import EasyProcess 258 | 259 | from pyvirtualdisplay import Display 260 | 261 | with Display(visible=True, size=(220, 180), bgcolor="black"): 262 | with Display(visible=True, size=(200, 160), bgcolor="white"): 263 | with Display(visible=True, size=(180, 140), bgcolor="black"): 264 | with Display(visible=True, size=(160, 120), bgcolor="white"): 265 | with Display(visible=True, size=(140, 100), bgcolor="black"): 266 | with Display(visible=True, size=(120, 80), bgcolor="white"): 267 | with Display(visible=True, size=(100, 60), bgcolor="black"): 268 | with EasyProcess(["xmessage", "hello"]) as proc: 269 | proc.wait() 270 | 271 | ``` 272 | 273 | 274 | Run it: 275 | ```console 276 | $ python3 -m pyvirtualdisplay.examples.nested 277 | ``` 278 | 279 | Image: 280 | 281 | ![](doc/gen/python3_-m_pyvirtualdisplay.examples.nested.png) 282 | 283 | xauth 284 | ===== 285 | 286 | Some programs require a functional Xauthority file. PyVirtualDisplay can 287 | generate one and set the appropriate environment variables if you pass 288 | ``use_xauth=True`` to the ``Display`` constructor. Note however that this 289 | feature needs ``xauth`` installed, otherwise a 290 | ``pyvirtualdisplay.xauth.NotFoundError`` is raised. 291 | 292 | Mouse cursor 293 | ============ 294 | 295 | The cursor can be disabled in Xvfb using an extra argument which is passed directly to Xvfb: 296 | ```py 297 | with Display(backend="xvfb", extra_args=["-nocursor"]): 298 | ... 299 | ``` 300 | 301 | Based on Xvfb help: 302 | ``` 303 | ... 304 | -nocursor disable the cursor 305 | ... 306 | ``` 307 | 308 | Concurrency 309 | =========== 310 | 311 | If more X servers are started at the same time then there is race for free display numbers. 312 | 313 | _"Recent X servers as of version 1.13 (Xvfb, too) support the -displayfd command line option: It will make the X server choose the display itself"_ 314 | https://stackoverflow.com/questions/2520704/find-a-free-x11-display-number/ 315 | 316 | Version 1.13 was released in 2012: https://www.x.org/releases/individual/xserver/ 317 | 318 | First help text is checked (e.g. `Xvfb -help`) to find if `-displayfd` flag is available. 319 | If `-displayfd` flag is available then it is used to choose the display number. 320 | If not then a free display number is generated and there are 10 retries by default 321 | which should be enough for starting 10 X servers at the same time. 322 | 323 | `displayfd` usage is disabled on macOS because it doesn't work with XQuartz-2.7.11, always 0 is returned. 324 | 325 | Thread safety 326 | ============= 327 | 328 | All previous examples are not thread-safe, because `pyvirtualdisplay` replaces `$DISPLAY` environment variable in global [`os.environ`][environ] in `start()` and sets back to original value in `stop()`. 329 | To make it thread-safe you have to manage the `$DISPLAY` variable. 330 | Set `manage_global_env` to `False` in constructor. 331 | 332 | ```py 333 | # pyvirtualdisplay/examples/threadsafe.py 334 | 335 | "Start Xvfb server and open xmessage window. Thread safe." 336 | 337 | import threading 338 | 339 | from easyprocess import EasyProcess 340 | 341 | from pyvirtualdisplay.smartdisplay import SmartDisplay 342 | 343 | 344 | def thread_function(index): 345 | # manage_global_env=False is thread safe 346 | with SmartDisplay(manage_global_env=False) as disp: 347 | cmd = ["xmessage", str(index)] 348 | # disp.new_display_var should be used for new processes 349 | # disp.env() copies global os.environ and adds disp.new_display_var 350 | with EasyProcess(cmd, env=disp.env()): 351 | img = disp.waitgrab() 352 | img.save("xmessage{}.png".format(index)) 353 | 354 | 355 | t1 = threading.Thread(target=thread_function, args=(1,)) 356 | t2 = threading.Thread(target=thread_function, args=(2,)) 357 | t1.start() 358 | t2.start() 359 | t1.join() 360 | t2.join() 361 | 362 | ``` 363 | 364 | 365 | Run it: 366 | ```console 367 | $ python3 -m pyvirtualdisplay.examples.threadsafe 368 | ``` 369 | 370 | Images: 371 | 372 | ![](doc/gen/xmessage1.png) 373 | ![](doc/gen/xmessage2.png) 374 | 375 | 376 | Hierarchy 377 | ========= 378 | 379 | ![Alt text](https://g.gravizo.com/source/svg?https%3A%2F%2Fraw.githubusercontent.com/ponty/pyvirtualdisplay/master/doc/hierarchy.dot) 380 | 381 | [1]: http://en.wikipedia.org/wiki/Xvfb 382 | [2]: http://en.wikipedia.org/wiki/Xephyr 383 | [3]: https://tigervnc.org/ 384 | [pillow]: https://pillow.readthedocs.io 385 | [environ]: https://docs.python.org/3/library/os.html#os.environ 386 | [EasyProcess]: https://github.com/ponty/EasyProcess -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.configure(2) do |config| 2 | config.vm.box = "ubuntu/jammy64" 3 | 4 | config.vm.provider "virtualbox" do |vb| 5 | vb.name = "pyvirtualdisplay_ubuntu2204_main" 6 | # vb.gui = true 7 | vb.memory = "2048" 8 | end 9 | 10 | config.vm.provision "shell", path: "tests/vagrant/ubuntu2204.sh" 11 | 12 | config.ssh.extra_args = ["-t", "cd /vagrant; bash --login"] 13 | end 14 | -------------------------------------------------------------------------------- /clean.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import pathlib 3 | import shutil 4 | 5 | [shutil.rmtree(p) for p in pathlib.Path(".").glob(".mypy_cache")] 6 | [shutil.rmtree(p) for p in pathlib.Path(".").glob(".pytest_cache")] 7 | [shutil.rmtree(p) for p in pathlib.Path(".").glob(".tox")] 8 | [shutil.rmtree(p) for p in pathlib.Path(".").glob("dist")] 9 | [shutil.rmtree(p) for p in pathlib.Path(".").glob("*.egg-info")] 10 | [shutil.rmtree(p) for p in pathlib.Path(".").glob("build")] 11 | [p.unlink() for p in pathlib.Path(".").rglob("*.py[co]")] 12 | [p.rmdir() for p in pathlib.Path(".").rglob("__pycache__")] 13 | [p.unlink() for p in pathlib.Path(".").rglob("*.log")] 14 | -------------------------------------------------------------------------------- /doc/gen/python3_-m_pyvirtualdisplay.examples.lowres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponty/PyVirtualDisplay/1d0335836d1673ec8b872debf58cf2d862c5d54d/doc/gen/python3_-m_pyvirtualdisplay.examples.lowres.png -------------------------------------------------------------------------------- /doc/gen/python3_-m_pyvirtualdisplay.examples.lowres.txt: -------------------------------------------------------------------------------- 1 | $ python3 -m pyvirtualdisplay.examples.lowres 2 | -------------------------------------------------------------------------------- /doc/gen/python3_-m_pyvirtualdisplay.examples.nested.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponty/PyVirtualDisplay/1d0335836d1673ec8b872debf58cf2d862c5d54d/doc/gen/python3_-m_pyvirtualdisplay.examples.nested.png -------------------------------------------------------------------------------- /doc/gen/python3_-m_pyvirtualdisplay.examples.nested.txt: -------------------------------------------------------------------------------- 1 | $ python3 -m pyvirtualdisplay.examples.nested 2 | -------------------------------------------------------------------------------- /doc/gen/python3_-m_pyvirtualdisplay.examples.screenshot.txt: -------------------------------------------------------------------------------- 1 | $ python3 -m pyvirtualdisplay.examples.screenshot 2 | -------------------------------------------------------------------------------- /doc/gen/python3_-m_pyvirtualdisplay.examples.threadsafe.txt: -------------------------------------------------------------------------------- 1 | $ python3 -m pyvirtualdisplay.examples.threadsafe 2 | -------------------------------------------------------------------------------- /doc/gen/python3_-m_pyvirtualdisplay.examples.vncserver.txt: -------------------------------------------------------------------------------- 1 | $ python3 -m pyvirtualdisplay.examples.vncserver 2 | -------------------------------------------------------------------------------- /doc/gen/vncviewer_localhost:5904.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponty/PyVirtualDisplay/1d0335836d1673ec8b872debf58cf2d862c5d54d/doc/gen/vncviewer_localhost:5904.png -------------------------------------------------------------------------------- /doc/gen/vncviewer_localhost:5904.txt: -------------------------------------------------------------------------------- 1 | $ vncviewer localhost:5904 2 | -------------------------------------------------------------------------------- /doc/gen/xmessage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponty/PyVirtualDisplay/1d0335836d1673ec8b872debf58cf2d862c5d54d/doc/gen/xmessage.png -------------------------------------------------------------------------------- /doc/gen/xmessage1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponty/PyVirtualDisplay/1d0335836d1673ec8b872debf58cf2d862c5d54d/doc/gen/xmessage1.png -------------------------------------------------------------------------------- /doc/gen/xmessage2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponty/PyVirtualDisplay/1d0335836d1673ec8b872debf58cf2d862c5d54d/doc/gen/xmessage2.png -------------------------------------------------------------------------------- /doc/generate-doc.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import logging 3 | import os 4 | from time import sleep 5 | 6 | from easyprocess import EasyProcess 7 | from entrypoint2 import entrypoint 8 | 9 | from pyvirtualdisplay.smartdisplay import SmartDisplay 10 | 11 | # (cmd,grab,background) 12 | commands = [ 13 | ("python3 -m pyvirtualdisplay.examples.threadsafe", False, False), 14 | ("python3 -m pyvirtualdisplay.examples.screenshot", False, False), 15 | ("python3 -m pyvirtualdisplay.examples.lowres", True, True), 16 | ("python3 -m pyvirtualdisplay.examples.nested", True, True), 17 | ("python3 -m pyvirtualdisplay.examples.vncserver", False, True), 18 | ("vncviewer localhost:5904", True, True), 19 | ] 20 | 21 | 22 | def screenshot(cmd, fname): 23 | logging.info("%s %s", cmd, fname) 24 | # fpath = "docs/_img/%s" % fname 25 | # if os.path.exists(fpath): 26 | # os.remove(fpath) 27 | with SmartDisplay() as disp: 28 | with EasyProcess(cmd): 29 | img = disp.waitgrab() 30 | img.save(fname) 31 | 32 | 33 | def empty_dir(dir): 34 | files = glob.glob(os.path.join(dir, "*")) 35 | for f in files: 36 | os.remove(f) 37 | 38 | 39 | @entrypoint 40 | def main(): 41 | EasyProcess("killall Xvnc").call() 42 | gendir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gen") 43 | logging.info("gendir: %s", gendir) 44 | os.makedirs(gendir, exist_ok=True) 45 | empty_dir(gendir) 46 | pls = [] 47 | try: 48 | os.chdir("gen") 49 | for cmd, grab, bg in commands: 50 | sleep(1) 51 | 52 | with SmartDisplay() as disp: 53 | logging.info("======== cmd: %s", cmd) 54 | fname_base = cmd.replace(" ", "_") 55 | fname = fname_base + ".txt" 56 | # logging.info("cmd: %s", cmd) 57 | print("file name: %s" % fname) 58 | with open(fname, "w") as f: 59 | f.write("$ " + cmd + "\n") 60 | if bg: 61 | p = EasyProcess(cmd).start() 62 | else: 63 | p = EasyProcess(cmd).call() 64 | f.write(p.stdout) 65 | f.write(p.stderr) 66 | pls += [p] 67 | if grab: 68 | png = fname_base + ".png" 69 | sleep(1) 70 | img = disp.waitgrab() 71 | logging.info("saving %s", png) 72 | img.save(png) 73 | finally: 74 | os.chdir("..") 75 | for p in reversed(pls): 76 | p.stop() 77 | EasyProcess("killall Xvnc").call() 78 | embedme = EasyProcess(["embedme", "../README.md"]) 79 | embedme.call() 80 | print(embedme.stdout) 81 | assert embedme.return_code == 0 82 | assert "but file does not exist" not in embedme.stdout 83 | -------------------------------------------------------------------------------- /doc/hierarchy.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | rankdir=LR; 3 | node [fontsize=8,style=filled, fillcolor=white]; 4 | fontsize=8; 5 | 6 | subgraph cluster_0 { 7 | label = "pyvirtualdisplay"; 8 | style=filled; 9 | subgraph cluster_2 { 10 | style=filled; 11 | fillcolor=white; 12 | label = "wrappers"; 13 | 14 | XvfbDisplay; 15 | XephyrDisplay; 16 | XvncDisplay; 17 | } 18 | Display -> XvfbDisplay; 19 | Display -> XephyrDisplay; 20 | Display -> XvncDisplay; 21 | SmartDisplay -> Display 22 | } 23 | XvfbDisplay -> Xvfb; 24 | XephyrDisplay -> Xephyr; 25 | XvncDisplay -> Xvnc; 26 | 27 | application -> Display; 28 | application -> SmartDisplay; 29 | 30 | SmartDisplay -> Pillow; 31 | 32 | } 33 | -------------------------------------------------------------------------------- /format-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | autoflake -i -r --remove-all-unused-imports . 4 | autoflake -i -r --remove-unused-variables . 5 | isort . 6 | black . 7 | -------------------------------------------------------------------------------- /lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | # TODO: max-complexity=10 4 | python3 -m flake8 . --max-complexity=11 --max-line-length=127 --extend-ignore=E203 5 | python3 -m mypy "pyvirtualdisplay" 6 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | log_level=DEBUG 3 | log_format=%(asctime)s.%(msecs)03d %(name)s %(levelname)s %(message)s 4 | log_date_format=%Y-%m-%d %H:%M:%S 5 | #log_cli=true -------------------------------------------------------------------------------- /pyvirtualdisplay/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pyvirtualdisplay.about import __version__ 4 | from pyvirtualdisplay.display import Display 5 | 6 | Display # ignore unused 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | log = logging.getLogger(__name__) 11 | log.debug("version=%s", __version__) 12 | -------------------------------------------------------------------------------- /pyvirtualdisplay/about.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.0" 2 | -------------------------------------------------------------------------------- /pyvirtualdisplay/abstractdisplay.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import logging 3 | import os 4 | import select 5 | import subprocess 6 | import tempfile 7 | import time 8 | from threading import Lock 9 | 10 | from pyvirtualdisplay import xauth 11 | from pyvirtualdisplay.util import get_helptext, platform_is_osx 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | # try: 16 | # import fcntl 17 | # except ImportError: 18 | # fcntl = None 19 | 20 | _mutex = Lock() 21 | _mutex_popen = Lock() 22 | 23 | 24 | _MIN_DISPLAY_NR = 1000 25 | _USED_DISPLAY_NR_LIST = [] 26 | 27 | _X_START_TIME_STEP = 0.1 28 | _X_START_WAIT = 0.1 29 | 30 | 31 | class XStartTimeoutError(Exception): 32 | pass 33 | 34 | 35 | class XStartError(Exception): 36 | pass 37 | 38 | 39 | def _lock_files(): 40 | tmpdir = "/tmp" 41 | try: 42 | ls = os.listdir(tmpdir) 43 | except FileNotFoundError: 44 | log.warning("missing /tmp") 45 | return [] 46 | pattern = ".X*-lock" 47 | names = fnmatch.filter(ls, pattern) 48 | ls = [os.path.join(tmpdir, child) for child in names] 49 | ls = [p for p in ls if os.path.isfile(p)] 50 | return ls 51 | 52 | 53 | def _search_for_display(): 54 | # search for free display 55 | ls = list(map(lambda x: int(x.split("X")[1].split("-")[0]), _lock_files())) 56 | if len(ls): 57 | display = max(_MIN_DISPLAY_NR, max(ls) + 3) 58 | else: 59 | display = _MIN_DISPLAY_NR 60 | 61 | return display 62 | 63 | 64 | def _env_bool(key, default_val): 65 | # '0'->false, '1'->true 66 | 67 | val = os.environ.get(key) 68 | if val: 69 | log.debug("%s=%s", key, val) 70 | return bool(int(val)) 71 | else: 72 | return default_val 73 | 74 | 75 | def _env_displayfd(default_val): 76 | key = "PYVIRTUALDISPLAY_DISPLAYFD" 77 | return _env_bool(key, default_val) 78 | 79 | 80 | class AbstractDisplay(object): 81 | """ 82 | Common parent for X servers (Xvfb,Xephyr,Xvnc) 83 | """ 84 | 85 | def __init__( 86 | self, program, use_xauth, retries, timeout, extra_args, manage_global_env 87 | ): 88 | self._extra_args = extra_args 89 | self._retries = retries 90 | self._timeout = timeout 91 | self._program = program 92 | self.stdout = None 93 | self.stderr = None 94 | self.old_display_var = None 95 | self._subproc = None 96 | self.display = None 97 | self._is_started = False 98 | self._manage_global_env = manage_global_env 99 | self._reset_global_env = False 100 | self._pipe_wfd = None 101 | self._retries_current = 0 102 | self._display_to_rm = None 103 | 104 | helptext = get_helptext(program) 105 | self._has_displayfd = "-displayfd" in helptext 106 | if not self._has_displayfd: 107 | log.debug("-displayfd flag is missing.") 108 | 109 | # TODO: macos: displayfd is available on XQuartz-2.7.11 but it doesn't work, always 0 is returned 110 | if platform_is_osx(): 111 | self._has_displayfd = False 112 | self._has_displayfd = _env_displayfd(self._has_displayfd) 113 | 114 | self._check_flags(helptext) 115 | 116 | if use_xauth and not xauth.is_installed(): 117 | raise xauth.NotFoundError() 118 | 119 | self._use_xauth = use_xauth 120 | self._old_xauth = None 121 | self._xauth_filename = None 122 | 123 | def _check_flags(self, helptext): 124 | pass 125 | 126 | def _cmd(self): 127 | raise NotImplementedError() 128 | 129 | def _redirect_display(self, on): 130 | """ 131 | on: 132 | * True -> set $DISPLAY to virtual screen 133 | * False -> set $DISPLAY to original screen 134 | 135 | :param on: bool 136 | """ 137 | d = self.new_display_var if on else self.old_display_var 138 | if d is None: 139 | log.debug("unset $DISPLAY") 140 | try: 141 | del os.environ["DISPLAY"] 142 | except KeyError: 143 | log.warning("$DISPLAY was already unset.") 144 | else: 145 | log.debug("set $DISPLAY=%s", d) 146 | os.environ["DISPLAY"] = d 147 | 148 | def _env(self): 149 | env = os.environ.copy() 150 | env["DISPLAY"] = self.new_display_var 151 | return env 152 | 153 | def start(self): 154 | """ 155 | start display 156 | 157 | :rtype: self 158 | """ 159 | if self._is_started: 160 | raise XStartError(self, "Display was started twice.") 161 | self._is_started = True 162 | 163 | i = 0 164 | while True: 165 | self._retries_current = i + 1 166 | try: 167 | if self._has_displayfd: 168 | self._start1_has_displayfd() 169 | else: 170 | self._start1() 171 | break 172 | except (XStartError, XStartTimeoutError): 173 | log.warning("start failed %s", i + 1) 174 | time.sleep(0.05) 175 | i += 1 176 | self._kill_subproc() 177 | if self._retries and i >= self._retries: 178 | raise XStartError( 179 | "No success after %s retries. Last stderr: %s" 180 | % (self._retries, self.stderr) 181 | ) 182 | if self._manage_global_env: 183 | self._redirect_display(True) 184 | self._reset_global_env = True 185 | 186 | def _popen(self, use_pass_fds): 187 | with _mutex_popen: 188 | if use_pass_fds: 189 | self._subproc = subprocess.Popen( 190 | self._command, 191 | pass_fds=[self._pipe_wfd], 192 | stdout=subprocess.PIPE, 193 | stderr=subprocess.PIPE, 194 | shell=False, 195 | ) 196 | else: 197 | self._subproc = subprocess.Popen( 198 | self._command, 199 | stdout=subprocess.PIPE, 200 | stderr=subprocess.PIPE, 201 | shell=False, 202 | ) 203 | 204 | def _start1_has_displayfd(self): 205 | # stdout doesn't work on osx -> create own pipe 206 | rfd, self._pipe_wfd = os.pipe() 207 | 208 | self._command = self._cmd() + self._extra_args 209 | log.debug("command: %s", self._command) 210 | 211 | self._popen(use_pass_fds=True) 212 | 213 | self.display = int(self._wait_for_pipe_text(rfd)) 214 | os.close(rfd) 215 | os.close(self._pipe_wfd) 216 | 217 | self.new_display_var = ":%s" % int(self.display) 218 | 219 | if self._use_xauth: 220 | self._setup_xauth() 221 | 222 | # https://github.com/ponty/PyVirtualDisplay/issues/2 223 | # https://github.com/ponty/PyVirtualDisplay/issues/14 224 | self.old_display_var = os.environ.get("DISPLAY", None) 225 | 226 | def _start1(self): 227 | with _mutex: 228 | self.display = _search_for_display() 229 | while self.display in _USED_DISPLAY_NR_LIST: 230 | self.display += 1 231 | self.new_display_var = ":%s" % int(self.display) 232 | 233 | _USED_DISPLAY_NR_LIST.append(self.display) 234 | self._display_to_rm = self.display 235 | 236 | self._command = self._cmd() + self._extra_args 237 | log.debug("command: %s", self._command) 238 | 239 | self._popen(use_pass_fds=False) 240 | 241 | self.new_display_var = ":%s" % int(self.display) 242 | 243 | if self._use_xauth: 244 | self._setup_xauth() 245 | 246 | # https://github.com/ponty/PyVirtualDisplay/issues/2 247 | # https://github.com/ponty/PyVirtualDisplay/issues/14 248 | self.old_display_var = os.environ.get("DISPLAY", None) 249 | 250 | # wait until X server is active 251 | start_time = time.time() 252 | 253 | d = self.new_display_var 254 | ok = False 255 | time.sleep(0.05) # give time for early exit 256 | while True: 257 | if not self.is_alive(): 258 | break 259 | 260 | try: 261 | xdpyinfo = subprocess.Popen( 262 | ["xdpyinfo"], 263 | env=self._env(), 264 | stdout=subprocess.PIPE, 265 | stderr=subprocess.PIPE, 266 | shell=False, 267 | ) 268 | _, _ = xdpyinfo.communicate() 269 | exit_code = xdpyinfo.returncode 270 | except FileNotFoundError: 271 | log.warning( 272 | "xdpyinfo was not found, X start can not be checked! Please install xdpyinfo!" 273 | ) 274 | time.sleep(_X_START_WAIT) # old method 275 | ok = True 276 | break 277 | # try: 278 | # xdpyinfo = EasyProcess(["xdpyinfo"], env=self._env()) 279 | # xdpyinfo.enable_stdout_log = False 280 | # xdpyinfo.enable_stderr_log = False 281 | # exit_code = xdpyinfo.call().return_code 282 | # except EasyProcessError: 283 | # log.warning( 284 | # "xdpyinfo was not found, X start can not be checked! Please install xdpyinfo!" 285 | # ) 286 | # time.sleep(_X_START_WAIT) # old method 287 | # ok = True 288 | # break 289 | 290 | if exit_code != 0: 291 | pass 292 | else: 293 | log.info('Successfully started X with display "%s".', d) 294 | ok = True 295 | break 296 | 297 | if time.time() - start_time >= self._timeout: 298 | break 299 | time.sleep(_X_START_TIME_STEP) 300 | if not self.is_alive(): 301 | log.warning("process exited early. stderr:%s", self.stderr) 302 | msg = "Failed to start process: %s" 303 | raise XStartError(msg % self) 304 | if not ok: 305 | msg = 'Failed to start X on display "%s" (xdpyinfo check failed, stderr:[%s]).' 306 | raise XStartTimeoutError(msg % (d, xdpyinfo.stderr)) 307 | 308 | def _wait_for_pipe_text(self, rfd): 309 | s = "" 310 | start_time = time.time() 311 | while True: 312 | (rfd_changed_ls, _, _) = select.select([rfd], [], [], 0.1) 313 | if not self.is_alive(): 314 | raise XStartError( 315 | "%s program closed. command: %s stderr: %s" 316 | % (self._program, self._command, self.stderr) 317 | ) 318 | if rfd in rfd_changed_ls: 319 | c = os.read(rfd, 1) 320 | if c == b"\n": 321 | break 322 | s += c.decode("ascii") 323 | 324 | # this timeout is for "eternal" hang. see #62 325 | if time.time() - start_time >= self._timeout: 326 | raise XStartTimeoutError( 327 | "No reply from program %s. command:%s" 328 | % ( 329 | self._program, 330 | self._command, 331 | ) 332 | ) 333 | return s 334 | 335 | def _kill_subproc(self): 336 | if self.is_alive(): 337 | try: 338 | self._subproc.kill() 339 | except OSError as oserror: 340 | log.debug("exception in kill:%s", oserror) 341 | 342 | self._subproc.wait() 343 | self._read_stdout_stderr() 344 | 345 | def stop(self): 346 | """ 347 | stop display 348 | 349 | :rtype: self 350 | """ 351 | if not self._is_started: 352 | raise XStartError("stop() is called before start().") 353 | 354 | if self._reset_global_env: 355 | self._redirect_display(False) 356 | 357 | self._kill_subproc() 358 | 359 | if self._use_xauth: 360 | self._clear_xauth() 361 | 362 | if self._display_to_rm: 363 | with _mutex: 364 | _USED_DISPLAY_NR_LIST.remove(self._display_to_rm) 365 | self._display_to_rm = None 366 | return self 367 | 368 | def _read_stdout_stderr(self): 369 | if self.stdout is None: 370 | (self.stdout, self.stderr) = self._subproc.communicate() 371 | 372 | log.debug("stdout=%s", self.stdout) 373 | log.debug("stderr=%s", self.stderr) 374 | 375 | def _setup_xauth(self): 376 | """ 377 | Set up the Xauthority file and the XAUTHORITY environment variable. 378 | """ 379 | handle, filename = tempfile.mkstemp( 380 | prefix="PyVirtualDisplay.", suffix=".Xauthority" 381 | ) 382 | self._xauth_filename = filename 383 | os.close(handle) 384 | # Save old environment 385 | self._old_xauth = {} 386 | self._old_xauth["AUTHFILE"] = os.getenv("AUTHFILE") 387 | self._old_xauth["XAUTHORITY"] = os.getenv("XAUTHORITY") 388 | 389 | os.environ["AUTHFILE"] = os.environ["XAUTHORITY"] = filename 390 | cookie = xauth.generate_mcookie() 391 | xauth.call("add", self.new_display_var, ".", cookie) 392 | 393 | def _clear_xauth(self): 394 | """ 395 | Clear the Xauthority file and restore the environment variables. 396 | """ 397 | os.remove(self._xauth_filename) 398 | for varname in ["AUTHFILE", "XAUTHORITY"]: 399 | if self._old_xauth[varname] is None: 400 | del os.environ[varname] 401 | else: 402 | os.environ[varname] = self._old_xauth[varname] 403 | self._old_xauth = None 404 | 405 | def __enter__(self): 406 | """used by the :keyword:`with` statement""" 407 | self.start() 408 | return self 409 | 410 | def __exit__(self, *exc_info): 411 | """used by the :keyword:`with` statement""" 412 | self.stop() 413 | 414 | def is_alive(self): 415 | if not self._subproc: 416 | return False 417 | # return self.return_code is None 418 | rc = self._subproc.poll() 419 | if rc is not None: 420 | # proc exited 421 | self._read_stdout_stderr() 422 | return rc is None 423 | 424 | # @property 425 | # def return_code(self): 426 | # if not self._subproc: 427 | # return None 428 | # rc = self._subproc.poll() 429 | # if rc is not None: 430 | # # proc exited 431 | # self._read_stdout_stderr() 432 | # return rc 433 | 434 | @property 435 | def pid(self): 436 | """ 437 | PID (:attr:`subprocess.Popen.pid`) 438 | 439 | :rtype: int 440 | """ 441 | if self._subproc: 442 | return self._subproc.pid 443 | -------------------------------------------------------------------------------- /pyvirtualdisplay/display.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional, Tuple 2 | 3 | from pyvirtualdisplay.xephyr import XephyrDisplay 4 | from pyvirtualdisplay.xvfb import XvfbDisplay 5 | from pyvirtualdisplay.xvnc import XvncDisplay 6 | 7 | _class_map = {"xvfb": XvfbDisplay, "xvnc": XvncDisplay, "xephyr": XephyrDisplay} 8 | 9 | 10 | class Display(object): 11 | """ 12 | Proxy class 13 | 14 | :param color_depth: [8, 16, 24, 32] 15 | :param size: screen size (width,height) 16 | :param bgcolor: background color ['black' or 'white'] 17 | :param visible: True -> Xephyr, False -> Xvfb 18 | :param backend: 'xvfb', 'xvnc' or 'xephyr', ignores ``visible`` 19 | :param xauth: If a Xauthority file should be created. 20 | :param manage_global_env: if True then $DISPLAY is set in os.environ 21 | which is not thread-safe. Use False to make it thread-safe. 22 | """ 23 | 24 | def __init__( 25 | self, 26 | backend: Optional[str] = None, 27 | visible: bool = False, 28 | size: Tuple[int, int] = (1024, 768), 29 | color_depth: int = 24, 30 | bgcolor: str = "black", 31 | use_xauth: bool = False, 32 | retries: int = 10, # zero: no limit 33 | timeout: int = 10 * 60, # seconds 34 | extra_args: List[str] = [], 35 | manage_global_env: bool = True, 36 | **kwargs 37 | ): 38 | self._color_depth = color_depth 39 | self._size = size 40 | self._bgcolor = bgcolor 41 | self._visible = visible 42 | self._backend = backend 43 | 44 | if not self._backend: 45 | if self._visible: 46 | self._backend = "xephyr" 47 | else: 48 | self._backend = "xvfb" 49 | 50 | cls = _class_map.get(self._backend) 51 | if not cls: 52 | raise ValueError("unknown backend: %s" % self._backend) 53 | 54 | self._obj = cls( 55 | size=size, 56 | color_depth=color_depth, 57 | bgcolor=bgcolor, 58 | retries=retries, 59 | timeout=timeout, 60 | use_xauth=use_xauth, 61 | extra_args=extra_args, 62 | manage_global_env=manage_global_env, 63 | **kwargs 64 | ) 65 | 66 | def start(self) -> "Display": 67 | """ 68 | start display 69 | 70 | :rtype: self 71 | """ 72 | self._obj.start() 73 | return self 74 | 75 | def stop(self) -> "Display": 76 | """ 77 | stop display 78 | 79 | :rtype: self 80 | """ 81 | self._obj.stop() 82 | return self 83 | 84 | def __enter__(self): 85 | """used by the :keyword:`with` statement""" 86 | self.start() 87 | return self 88 | 89 | def __exit__(self, *exc_info): 90 | """used by the :keyword:`with` statement""" 91 | self.stop() 92 | 93 | def is_alive(self) -> bool: 94 | return self._obj.is_alive() 95 | 96 | # @property 97 | # def return_code(self): 98 | # return self._obj.return_code 99 | 100 | @property 101 | def pid(self) -> int: 102 | """ 103 | PID (:attr:`subprocess.Popen.pid`) 104 | 105 | :rtype: int 106 | """ 107 | return self._obj.pid 108 | 109 | @property 110 | def display(self) -> int: 111 | """The new $DISPLAY variable as int. Example 1 if $DISPLAY=':1'""" 112 | return self._obj.display 113 | 114 | @property 115 | def new_display_var(self) -> str: 116 | """The new $DISPLAY variable like ':1'""" 117 | return self._obj.new_display_var 118 | 119 | def env(self) -> Dict[str, str]: 120 | """env() copies global os.environ and adds disp.new_display_var""" 121 | return self._obj._env() 122 | -------------------------------------------------------------------------------- /pyvirtualdisplay/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponty/PyVirtualDisplay/1d0335836d1673ec8b872debf58cf2d862c5d54d/pyvirtualdisplay/examples/__init__.py -------------------------------------------------------------------------------- /pyvirtualdisplay/examples/headless.py: -------------------------------------------------------------------------------- 1 | "Start Xvfb server. Open xmessage window." 2 | 3 | from easyprocess import EasyProcess 4 | 5 | from pyvirtualdisplay import Display 6 | 7 | with Display(visible=False, size=(100, 60)) as disp: 8 | with EasyProcess(["xmessage", "hello"]) as proc: 9 | proc.wait() 10 | -------------------------------------------------------------------------------- /pyvirtualdisplay/examples/lowres.py: -------------------------------------------------------------------------------- 1 | "Testing gnumeric on low resolution." 2 | from easyprocess import EasyProcess 3 | 4 | from pyvirtualdisplay import Display 5 | 6 | # start Xephyr 7 | with Display(visible=True, size=(320, 240)) as disp: 8 | # start Gnumeric 9 | with EasyProcess(["gnumeric"]) as proc: 10 | proc.wait() 11 | -------------------------------------------------------------------------------- /pyvirtualdisplay/examples/nested.py: -------------------------------------------------------------------------------- 1 | "Nested Xephyr servers" 2 | from easyprocess import EasyProcess 3 | 4 | from pyvirtualdisplay import Display 5 | 6 | with Display(visible=True, size=(220, 180), bgcolor="black"): 7 | with Display(visible=True, size=(200, 160), bgcolor="white"): 8 | with Display(visible=True, size=(180, 140), bgcolor="black"): 9 | with Display(visible=True, size=(160, 120), bgcolor="white"): 10 | with Display(visible=True, size=(140, 100), bgcolor="black"): 11 | with Display(visible=True, size=(120, 80), bgcolor="white"): 12 | with Display(visible=True, size=(100, 60), bgcolor="black"): 13 | with EasyProcess(["xmessage", "hello"]) as proc: 14 | proc.wait() 15 | -------------------------------------------------------------------------------- /pyvirtualdisplay/examples/screenshot.py: -------------------------------------------------------------------------------- 1 | "Create screenshot of xmessage in background using 'smartdisplay' submodule" 2 | from easyprocess import EasyProcess 3 | 4 | from pyvirtualdisplay.smartdisplay import SmartDisplay 5 | 6 | # 'SmartDisplay' instead of 'Display' 7 | # It has 'waitgrab()' method. 8 | # It has more dependencies than Display. 9 | with SmartDisplay() as disp: 10 | with EasyProcess(["xmessage", "hello"]): 11 | # wait until something is displayed on the virtual display (polling method) 12 | # and then take a fullscreen screenshot 13 | # and then crop it. Background is black. 14 | img = disp.waitgrab() 15 | img.save("xmessage.png") 16 | -------------------------------------------------------------------------------- /pyvirtualdisplay/examples/threadsafe.py: -------------------------------------------------------------------------------- 1 | "Start Xvfb server and open xmessage window. Thread safe." 2 | 3 | import threading 4 | 5 | from easyprocess import EasyProcess 6 | 7 | from pyvirtualdisplay.smartdisplay import SmartDisplay 8 | 9 | 10 | def thread_function(index): 11 | # manage_global_env=False is thread safe 12 | with SmartDisplay(manage_global_env=False) as disp: 13 | cmd = ["xmessage", str(index)] 14 | # disp.new_display_var should be used for new processes 15 | # disp.env() copies global os.environ and adds disp.new_display_var 16 | with EasyProcess(cmd, env=disp.env()): 17 | img = disp.waitgrab() 18 | img.save("xmessage{}.png".format(index)) 19 | 20 | 21 | t1 = threading.Thread(target=thread_function, args=(1,)) 22 | t2 = threading.Thread(target=thread_function, args=(2,)) 23 | t1.start() 24 | t2.start() 25 | t1.join() 26 | t2.join() 27 | -------------------------------------------------------------------------------- /pyvirtualdisplay/examples/vncserver.py: -------------------------------------------------------------------------------- 1 | "Start virtual VNC server. Connect with: vncviewer localhost:5904" 2 | 3 | from easyprocess import EasyProcess 4 | 5 | from pyvirtualdisplay import Display 6 | 7 | with Display(backend="xvnc", size=(100, 60), rfbport=5904) as disp: 8 | with EasyProcess(["xmessage", "hello"]) as proc: 9 | proc.wait() 10 | -------------------------------------------------------------------------------- /pyvirtualdisplay/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ponty/PyVirtualDisplay/1d0335836d1673ec8b872debf58cf2d862c5d54d/pyvirtualdisplay/py.typed -------------------------------------------------------------------------------- /pyvirtualdisplay/smartdisplay.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from PIL import Image, ImageChops 5 | from PIL.ImageGrab import grab 6 | 7 | from pyvirtualdisplay import Display 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class DisplayTimeoutError(Exception): 13 | pass 14 | 15 | 16 | def autocrop(im, bgcolor): 17 | """Crop borders off an image. 18 | 19 | :param im: Source image. 20 | :param bgcolor: Background color, using either a color tuple. 21 | :return: An image without borders, or None if there's no actual content in the image. 22 | """ 23 | if im.mode != "RGB": 24 | im = im.convert("RGB") 25 | bg = Image.new("RGB", im.size, bgcolor) 26 | diff = ImageChops.difference(im, bg) 27 | bbox = diff.getbbox() 28 | if bbox: 29 | return im.crop(bbox) 30 | return None # no contents 31 | 32 | 33 | class SmartDisplay(Display): 34 | def autocrop(self, im): 35 | """Crop borders off an image. 36 | 37 | :param im: Source image. 38 | :return: An image without borders, or None if there's no actual content in the image. 39 | """ 40 | return autocrop(im, self._bgcolor) 41 | 42 | def grab(self, autocrop=True): 43 | # TODO: use Xvfb fbdir option for screenshot 44 | img = grab(xdisplay=self.new_display_var) 45 | 46 | if autocrop: 47 | img = self.autocrop(img) 48 | return img 49 | 50 | def waitgrab(self, timeout=60, autocrop=True, cb_imgcheck=None): 51 | """start process and create screenshot. 52 | Repeat screenshot until it is not empty and 53 | cb_imgcheck callback function returns True 54 | for current screenshot. 55 | 56 | :param autocrop: True -> crop screenshot 57 | :param timeout: int 58 | :param cb_imgcheck: None or callback for testing img, 59 | True = accept img, 60 | False = reject img 61 | """ 62 | t = 0 63 | sleep_time = 0.3 # for fast windows 64 | repeat_time = 0.5 65 | while 1: 66 | log.debug("sleeping %s secs" % str(sleep_time)) 67 | time.sleep(sleep_time) 68 | t += sleep_time 69 | img = self.grab(autocrop=False) 70 | img_crop = self.autocrop(img) 71 | if autocrop: 72 | img = img_crop 73 | if img_crop: 74 | if not cb_imgcheck: 75 | break 76 | if cb_imgcheck(img): 77 | break 78 | sleep_time = repeat_time 79 | repeat_time += 0.5 # progressive 80 | if t > timeout: 81 | msg = "Timeout! elapsed time:%s timeout:%s " % (t, timeout) 82 | raise DisplayTimeoutError(msg) 83 | # break 84 | 85 | log.debug("screenshot is empty, next try..") 86 | assert img 87 | # if not img: 88 | # log.debug('screenshot is empty!') 89 | return img 90 | -------------------------------------------------------------------------------- /pyvirtualdisplay/util.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | 5 | def get_helptext(program): 6 | cmd = [program, "-help"] 7 | 8 | # py3.7+ 9 | # p = subprocess.run(cmd, capture_output=True) 10 | # stderr = p.stderr 11 | 12 | # py3.6 also 13 | p = subprocess.Popen( 14 | cmd, 15 | stdout=subprocess.PIPE, 16 | stderr=subprocess.PIPE, 17 | shell=False, 18 | ) 19 | _, stderr = p.communicate() 20 | 21 | helptext = stderr.decode("utf-8", "ignore") 22 | return helptext 23 | 24 | 25 | def platform_is_osx(): 26 | return sys.platform == "darwin" 27 | -------------------------------------------------------------------------------- /pyvirtualdisplay/xauth.py: -------------------------------------------------------------------------------- 1 | """Utility functions for xauth.""" 2 | import hashlib 3 | import os 4 | import subprocess 5 | 6 | 7 | class NotFoundError(Exception): 8 | """Error when xauth was not found.""" 9 | 10 | 11 | def is_installed(): 12 | """ 13 | Return whether or not xauth is installed. 14 | """ 15 | try: 16 | xauth = subprocess.Popen( 17 | ["xauth", "-V"], 18 | # env=self._env(), 19 | stdout=subprocess.PIPE, 20 | stderr=subprocess.PIPE, 21 | ) 22 | _, _ = xauth.communicate() 23 | # p = EasyProcess(["xauth", "-V"]) 24 | # p.enable_stdout_log = False 25 | # p.enable_stderr_log = False 26 | # p.call() 27 | except FileNotFoundError: 28 | return False 29 | else: 30 | return True 31 | 32 | 33 | def generate_mcookie(): 34 | """ 35 | Generate a cookie string suitable for xauth. 36 | """ 37 | data = os.urandom(16) # 16 bytes = 128 bit 38 | return hashlib.md5(data).hexdigest() 39 | 40 | 41 | def call(*args): 42 | """ 43 | Call xauth with the given args. 44 | """ 45 | xauth = subprocess.Popen( 46 | ["xauth"] + list(args), 47 | # env=self._env(), 48 | stdout=subprocess.PIPE, 49 | stderr=subprocess.PIPE, 50 | ) 51 | _, _ = xauth.communicate() 52 | # EasyProcess(["xauth"] + list(args)).call() 53 | -------------------------------------------------------------------------------- /pyvirtualdisplay/xephyr.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pyvirtualdisplay.abstractdisplay import AbstractDisplay 4 | 5 | log = logging.getLogger(__name__) 6 | 7 | PROGRAM = "Xephyr" 8 | 9 | 10 | class XephyrDisplay(AbstractDisplay): 11 | """ 12 | Xephyr wrapper 13 | 14 | Xephyr is an X server outputting to a window on a pre-existing X display 15 | """ 16 | 17 | def __init__( 18 | self, 19 | size=(1024, 768), 20 | color_depth=24, 21 | bgcolor="black", 22 | use_xauth=False, 23 | retries=10, 24 | timeout=600, 25 | extra_args=[], 26 | manage_global_env=True, 27 | parent=None, 28 | ): 29 | """ 30 | :param bgcolor: 'black' or 'white' 31 | """ 32 | self._color_depth = color_depth 33 | self._size = size 34 | self._bgcolor = bgcolor 35 | self._parent = parent 36 | 37 | AbstractDisplay.__init__( 38 | self, 39 | PROGRAM, 40 | use_xauth=use_xauth, 41 | retries=retries, 42 | timeout=timeout, 43 | extra_args=extra_args, 44 | manage_global_env=manage_global_env, 45 | ) 46 | 47 | def _check_flags(self, helptext): 48 | self._has_resizeable = "-resizeable" in helptext 49 | 50 | def _cmd(self): 51 | cmd = ( 52 | [ 53 | PROGRAM, 54 | ] 55 | + (["-parent", self._parent] if self._parent else []) 56 | + [ 57 | dict(black="-br", white="-wr")[self._bgcolor], 58 | "-screen", 59 | "x".join(map(str, list(self._size) + [self._color_depth])), 60 | ] 61 | ) 62 | if self._has_displayfd: 63 | cmd += ["-displayfd", str(self._pipe_wfd)] 64 | else: 65 | cmd += [self.new_display_var] 66 | 67 | if self._has_resizeable: 68 | cmd += ["-resizeable"] 69 | return cmd 70 | -------------------------------------------------------------------------------- /pyvirtualdisplay/xvfb.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pyvirtualdisplay.abstractdisplay import AbstractDisplay 4 | 5 | log = logging.getLogger(__name__) 6 | 7 | PROGRAM = "Xvfb" 8 | 9 | 10 | class XvfbDisplay(AbstractDisplay): 11 | """ 12 | Xvfb wrapper 13 | 14 | Xvfb is an X server that can run on machines with no display 15 | hardware and no physical input devices. It emulates a dumb 16 | framebuffer using virtual memory. 17 | """ 18 | 19 | def __init__( 20 | self, 21 | size=(1024, 768), 22 | color_depth=24, 23 | bgcolor="black", 24 | use_xauth=False, 25 | fbdir=None, 26 | dpi=None, 27 | retries=10, 28 | timeout=600, 29 | extra_args=[], 30 | manage_global_env=True, 31 | ): 32 | """ 33 | :param bgcolor: 'black' or 'white' 34 | :param fbdir: If non-null, the virtual screen is memory-mapped 35 | to a file in the given directory ('-fbdir' option) 36 | :param dpi: screen resolution in dots per inch if not None 37 | """ 38 | self._screen = 0 39 | self._size = size 40 | self._color_depth = color_depth 41 | self._bgcolor = bgcolor 42 | self._fbdir = fbdir 43 | self._dpi = dpi 44 | 45 | AbstractDisplay.__init__( 46 | self, 47 | PROGRAM, 48 | use_xauth=use_xauth, 49 | retries=retries, 50 | timeout=timeout, 51 | extra_args=extra_args, 52 | manage_global_env=manage_global_env, 53 | ) 54 | 55 | def _check_flags(self, helptext): 56 | pass 57 | 58 | def _cmd(self): 59 | cmd = [ 60 | dict(black="-br", white="-wr")[self._bgcolor], 61 | "-nolisten", 62 | "tcp", 63 | "-screen", 64 | str(self._screen), 65 | "x".join(map(str, list(self._size) + [self._color_depth])), 66 | ] 67 | if self._fbdir: 68 | cmd += ["-fbdir", self._fbdir] 69 | if self._dpi is not None: 70 | cmd += ["-dpi", str(self._dpi)] 71 | if self._has_displayfd: 72 | cmd += ["-displayfd", str(self._pipe_wfd)] 73 | else: 74 | cmd += [self.new_display_var] 75 | return [PROGRAM] + cmd 76 | -------------------------------------------------------------------------------- /pyvirtualdisplay/xvnc.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pyvirtualdisplay.abstractdisplay import AbstractDisplay 4 | 5 | log = logging.getLogger(__name__) 6 | 7 | PROGRAM = "Xvnc" 8 | 9 | 10 | class XvncDisplay(AbstractDisplay): 11 | """ 12 | Xvnc wrapper 13 | """ 14 | 15 | def __init__( 16 | self, 17 | size=(1024, 768), 18 | color_depth=24, 19 | bgcolor="black", 20 | use_xauth=False, 21 | rfbport=5900, 22 | rfbauth=None, 23 | retries=10, 24 | timeout=600, 25 | extra_args=[], 26 | manage_global_env=True, 27 | ): 28 | """ 29 | :param bgcolor: 'black' or 'white' 30 | :param rfbport: Specifies the TCP port on which Xvnc listens for connections from viewers 31 | (the protocol used in VNC is called RFB - "remote framebuffer"). 32 | The default is 5900 plus the display number. 33 | :param rfbauth: Specifies the file containing the password used to authenticate viewers. 34 | """ 35 | self._size = size 36 | self._color_depth = color_depth 37 | self._bgcolor = bgcolor 38 | self._rfbport = rfbport 39 | self._rfbauth = rfbauth 40 | self._has_SecurityTypes = True 41 | 42 | AbstractDisplay.__init__( 43 | self, 44 | PROGRAM, 45 | use_xauth=use_xauth, 46 | retries=retries, 47 | timeout=timeout, 48 | extra_args=extra_args, 49 | manage_global_env=manage_global_env, 50 | ) 51 | 52 | def _check_flags(self, helptext): 53 | self._has_SecurityTypes = "SecurityTypes" in helptext 54 | 55 | def _cmd(self): 56 | cmd = [ 57 | PROGRAM, 58 | "-depth", 59 | str(self._color_depth), 60 | "-geometry", 61 | "%dx%d" % (self._size[0], self._size[1]), 62 | "-rfbport", 63 | str(self._rfbport), 64 | ] 65 | 66 | if self._rfbauth: 67 | cmd += ["-rfbauth", str(self._rfbauth)] 68 | # default: 69 | # -SecurityTypes = VncAuth 70 | else: 71 | if self._has_SecurityTypes: 72 | cmd += ["-SecurityTypes", "None"] 73 | 74 | if self._has_displayfd: 75 | cmd += ["-displayfd", str(self._pipe_wfd)] 76 | else: 77 | cmd += [self.new_display_var] 78 | return cmd 79 | -------------------------------------------------------------------------------- /requirements-doc.txt: -------------------------------------------------------------------------------- 1 | autoflake 2 | isort 3 | black 4 | 5 | # pytest 6 | pillow 7 | entrypoint2 8 | vncdotool==0.13.0 9 | # psutil 10 | # for travis xenial 11 | # attrs 12 | # pytest-xdist 13 | EasyProcess 14 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pillow 3 | types-pillow 4 | entrypoint2 5 | vncdotool==0.13.0 6 | psutil 7 | # for travis xenial 8 | attrs 9 | pytest-xdist 10 | mypy 11 | flake8 12 | EasyProcess 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from setuptools import setup 4 | 5 | NAME = "pyvirtualdisplay" 6 | 7 | # get __version__ 8 | __version__ = None 9 | exec(open(os.path.join(NAME, "about.py")).read()) 10 | VERSION = __version__ 11 | 12 | PYPI_NAME = "PyVirtualDisplay" 13 | URL = "https://github.com/ponty/pyvirtualdisplay" 14 | DESCRIPTION = "python wrapper for Xvfb, Xephyr and Xvnc" 15 | LONG_DESCRIPTION = """pyvirtualdisplay is a python wrapper for Xvfb, Xephyr and Xvnc 16 | 17 | Documentation: https://github.com/ponty/pyvirtualdisplay/tree/""" 18 | LONG_DESCRIPTION += VERSION 19 | PACKAGES = [ 20 | NAME, 21 | NAME + ".examples", 22 | ] 23 | 24 | 25 | classifiers = [ 26 | # Get more strings from 27 | # http://www.python.org/pypi?%3Aaction=list_classifiers 28 | "License :: OSI Approved :: BSD License", 29 | "Natural Language :: English", 30 | "Operating System :: OS Independent", 31 | "Programming Language :: Python", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3 :: Only", 34 | "Programming Language :: Python :: 3.6", 35 | "Programming Language :: Python :: 3.7", 36 | "Programming Language :: Python :: 3.8", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | ] 42 | 43 | 44 | setup( 45 | name=PYPI_NAME, 46 | version=VERSION, 47 | description=DESCRIPTION, 48 | long_description=LONG_DESCRIPTION, 49 | long_description_content_type="text/x-rst", 50 | classifiers=classifiers, 51 | keywords="Xvfb Xephyr X wrapper", 52 | author="ponty", 53 | # author_email='', 54 | url=URL, 55 | license="BSD", 56 | packages=PACKAGES, 57 | # install_requires=install_requires, 58 | package_data={ 59 | NAME: ["py.typed"], 60 | }, 61 | ) 62 | -------------------------------------------------------------------------------- /tests/slowgui.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from easyprocess import EasyProcess 4 | 5 | 6 | def main(): 7 | time.sleep(10) 8 | EasyProcess(["xmessage", "hello"]).start() 9 | 10 | 11 | if __name__ == "__main__": 12 | main() 13 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | import pytest 4 | from tutil import has_xvnc, rfbport 5 | 6 | from pyvirtualdisplay import Display 7 | from pyvirtualdisplay.abstractdisplay import _USED_DISPLAY_NR_LIST, XStartError 8 | from pyvirtualdisplay.xephyr import XephyrDisplay 9 | from pyvirtualdisplay.xvfb import XvfbDisplay 10 | from pyvirtualdisplay.xvnc import XvncDisplay 11 | 12 | 13 | def test_virt(): 14 | vd = Display() 15 | # assert vd.return_code is None 16 | assert not vd.is_alive() 17 | vd.start() 18 | # assert vd.return_code is None 19 | assert vd.is_alive() 20 | vd.stop() 21 | # assert vd.return_code == 0 22 | assert not vd.is_alive() 23 | 24 | vd = Display().start().stop() 25 | # assert vd.return_code == 0 26 | assert not vd.is_alive() 27 | 28 | 29 | def test_nest(): 30 | vd = Display().start() 31 | assert vd.is_alive() 32 | 33 | nd = Display(visible=True).start().stop() 34 | 35 | # assert nd.return_code == 0 36 | assert not nd.is_alive() 37 | 38 | vd.stop() 39 | assert not vd.is_alive() 40 | 41 | 42 | def test_disp(): 43 | vd = Display().start() 44 | assert vd.is_alive() 45 | 46 | # d = Display(visible=True).start().sleep(2).stop() 47 | # .assertEquals(d.return_code, 0) 48 | 49 | d = Display(visible=False).start().stop() 50 | # assert d.return_code == 0 51 | assert not d.is_alive() 52 | 53 | vd.stop() 54 | assert not vd.is_alive() 55 | 56 | 57 | def test_repr_xvfb(): 58 | display = Display() 59 | print(repr(display)) 60 | 61 | display = Display(visible=False) 62 | print(repr(display)) 63 | 64 | display = Display(backend="xvfb") 65 | print(repr(display)) 66 | 67 | display = XvfbDisplay() 68 | print(repr(display)) 69 | 70 | 71 | if has_xvnc(): 72 | 73 | def test_repr_xvnc(): 74 | display = Display(backend="xvnc", rfbport=rfbport()) 75 | print(repr(display)) 76 | 77 | display = XvncDisplay() 78 | print(repr(display)) 79 | 80 | 81 | def test_repr_xephyr(): 82 | display = Display(visible=True) 83 | print(repr(display)) 84 | 85 | display = Display(backend="xephyr") 86 | print(repr(display)) 87 | 88 | display = XephyrDisplay() 89 | print(repr(display)) 90 | 91 | 92 | def test_stop_nostart(): 93 | with pytest.raises(XStartError): 94 | Display().stop() 95 | 96 | 97 | def test_double_start(): 98 | vd = Display() 99 | try: 100 | vd.start() 101 | with pytest.raises(XStartError): 102 | vd.start() 103 | finally: 104 | vd.stop() 105 | 106 | 107 | def test_double_stop(): 108 | vd = Display().start().stop() 109 | # assert vd.return_code == 0 110 | assert not vd.is_alive() 111 | vd.stop() 112 | # assert vd.return_code == 0 113 | assert not vd.is_alive() 114 | 115 | 116 | def test_stop_terminated(): 117 | vd = Display().start() 118 | assert vd.is_alive() 119 | vd._obj._subproc.kill() 120 | sleep(1) 121 | assert not vd.is_alive() 122 | vd.stop() 123 | # assert vd.return_code == 0 124 | assert not vd.is_alive() 125 | 126 | 127 | def test_no_backend(): 128 | with pytest.raises(ValueError): 129 | Display(backend="unknown") 130 | 131 | 132 | def test_color_xvfb(): 133 | with pytest.raises(XStartError): 134 | Display(color_depth=99).start().stop() 135 | Display(color_depth=16).start().stop() 136 | Display(color_depth=24).start().stop() 137 | Display(color_depth=8).start().stop() 138 | 139 | 140 | def test_color_xephyr(): 141 | with Display(): 142 | # requested screen depth not supported, setting to match hosts 143 | Display(backend="xephyr", color_depth=99).start().stop() 144 | 145 | Display(backend="xephyr", color_depth=16).start().stop() 146 | Display(backend="xephyr", color_depth=24).start().stop() 147 | Display(backend="xephyr", color_depth=8).start().stop() 148 | 149 | 150 | if has_xvnc(): 151 | 152 | def test_color_xvnc(): 153 | with pytest.raises(XStartError): 154 | with Display(backend="xvnc", color_depth=99, rfbport=rfbport()): 155 | pass 156 | with Display(backend="xvnc", color_depth=16, rfbport=rfbport()): 157 | pass 158 | with Display(backend="xvnc", color_depth=24, rfbport=rfbport()): 159 | pass 160 | # tigervnc no longer works 8-bit pseudocolors, 18.04 is OK 161 | # with Display(backend="xvnc", color_depth=8, rfbport=rfbport()): 162 | # pass 163 | 164 | 165 | def test_pid(): 166 | with Display() as d: 167 | assert d.pid > 0 168 | with XvfbDisplay() as d: 169 | assert d.pid > 0 170 | 171 | 172 | def test_bgcolor(): 173 | Display(bgcolor="black").start().stop() 174 | Display(bgcolor="white").start().stop() 175 | with pytest.raises(KeyError): 176 | Display(bgcolor="green").start().stop() 177 | 178 | 179 | def test_is_started(): 180 | # d = Display() 181 | # assert not d._is_started 182 | # d.start() 183 | # assert d._is_started 184 | # d.stop() 185 | # assert d._is_started 186 | 187 | # with Display() as d: 188 | # assert d._is_started 189 | # assert d._is_started 190 | 191 | with XvfbDisplay() as d: 192 | assert d._is_started 193 | assert d._is_started 194 | 195 | with Display(): 196 | with XephyrDisplay() as d: 197 | assert d._is_started 198 | assert d._is_started 199 | 200 | # with XvncDisplay() as d: 201 | # assert d._is_started 202 | # assert d._is_started 203 | 204 | 205 | def test_extra_args(): 206 | # Unrecognized option 207 | d = Display(extra_args=["willcrash"]) 208 | with pytest.raises(XStartError): 209 | d.start() 210 | 211 | with Display(): 212 | # -c turns off key-click 213 | with Display(visible=True, extra_args=["-c"]) as d: 214 | assert d.is_alive() 215 | assert not d.is_alive() 216 | 217 | with XephyrDisplay(extra_args=["-c"]) as d: 218 | assert d.is_alive() 219 | assert not d.is_alive() 220 | 221 | 222 | def test_display(): 223 | d = Display() 224 | assert d.display is None 225 | d.start() 226 | assert d.display >= 0 227 | 228 | d = XvfbDisplay() 229 | assert d.display is None 230 | d.start() 231 | assert d.display >= 0 232 | 233 | 234 | def test_USED_DISPLAY_NR_LIST(): 235 | n = len(_USED_DISPLAY_NR_LIST) 236 | vd = Display() 237 | vd._obj._has_displayfd = False 238 | vd.start() 239 | assert len(_USED_DISPLAY_NR_LIST) == n + 1 240 | 241 | vd2 = Display() 242 | vd2._obj._has_displayfd = False 243 | vd2.start() 244 | assert len(_USED_DISPLAY_NR_LIST) == n + 2 245 | 246 | vd2.stop() 247 | assert len(_USED_DISPLAY_NR_LIST) == n + 1 248 | 249 | vd.stop() 250 | assert len(_USED_DISPLAY_NR_LIST) == n + 0 251 | 252 | 253 | def test_USED_DISPLAY_NR_LIST_has_displayfd(): 254 | n = len(_USED_DISPLAY_NR_LIST) 255 | vd = Display() 256 | if not vd._obj._has_displayfd: 257 | return 258 | vd.start() 259 | assert len(_USED_DISPLAY_NR_LIST) == n 260 | 261 | vd2 = Display() 262 | vd2.start() 263 | assert len(_USED_DISPLAY_NR_LIST) == n 264 | 265 | vd2.stop() 266 | assert len(_USED_DISPLAY_NR_LIST) == n 267 | 268 | vd.stop() 269 | assert len(_USED_DISPLAY_NR_LIST) == n 270 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | from tempfile import TemporaryDirectory 5 | from time import sleep 6 | 7 | from easyprocess import EasyProcess 8 | from tutil import has_xvnc, kill_process_tree, prog_check, worker 9 | 10 | from pyvirtualdisplay import Display 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | python = sys.executable 16 | 17 | 18 | def test_screenshot(): 19 | owd = os.getcwd() 20 | with TemporaryDirectory(prefix="pyvirtualdisplay_") as tmpdirname: 21 | try: 22 | os.chdir(tmpdirname) 23 | p = EasyProcess([python, "-m", "pyvirtualdisplay.examples.screenshot"]) 24 | p.call() 25 | assert p.return_code == 0 26 | finally: 27 | os.chdir(owd) 28 | 29 | 30 | def test_headless(): 31 | p = EasyProcess([python, "-m", "pyvirtualdisplay.examples.headless"]).start() 32 | sleep(1) 33 | assert p.is_alive() 34 | # p.stop() 35 | kill_process_tree(p) 36 | 37 | 38 | def test_nested(): 39 | with Display(): 40 | p = EasyProcess([python, "-m", "pyvirtualdisplay.examples.nested"]).start() 41 | sleep(1) 42 | assert p.is_alive() 43 | # p.stop() 44 | kill_process_tree(p) 45 | 46 | 47 | if has_xvnc(): 48 | 49 | def test_vncserver(): 50 | if worker() == 0: 51 | p = EasyProcess( 52 | [python, "-m", "pyvirtualdisplay.examples.vncserver"] 53 | ).start() 54 | sleep(1) 55 | assert p.is_alive() 56 | # p.stop() 57 | kill_process_tree(p) 58 | 59 | 60 | if prog_check(["gnumeric", "-help"]): 61 | 62 | def test_lowres(): 63 | with Display(): 64 | p = EasyProcess([python, "-m", "pyvirtualdisplay.examples.lowres"]).start() 65 | sleep(1) 66 | assert p.is_alive() 67 | # p.stop() 68 | kill_process_tree(p) 69 | -------------------------------------------------------------------------------- /tests/test_race.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from time import sleep 3 | 4 | from easyprocess import EasyProcess 5 | from entrypoint2 import entrypoint 6 | from tutil import has_xvnc, worker 7 | 8 | from pyvirtualdisplay import Display 9 | 10 | # ubuntu 14.04 no displayfd 11 | # ubuntu 16.04 displayfd 12 | 13 | # Xephyr leaks shared memory segments until 18.04 14 | # https://gitlab.freedesktop.org/xorg/xserver/-/issues/130 15 | # approximately 4MB/call 16 | 17 | 18 | def test_race_100_xvfb(): 19 | check_n(100, "xvfb") 20 | 21 | 22 | # TODO: this fails with "Xephyr cannot open host display" 23 | # Xephyr bug? 24 | # def test_race_500_100_xephyr(): 25 | # for _ in range(500): 26 | # check_n(100, "xephyr") 27 | 28 | 29 | if has_xvnc(): 30 | 31 | def test_race_10_xvnc(): 32 | check_n(10, "xvnc") 33 | 34 | 35 | def check_n(n, backend): 36 | with Display(): 37 | ls = [] 38 | try: 39 | for i in range(n): 40 | cmd = [ 41 | sys.executable, 42 | __file__.rsplit(".", 1)[0] + ".py", 43 | str(i), 44 | backend, 45 | str(n), 46 | "--debug", 47 | ] 48 | p = EasyProcess(cmd) 49 | p.start() 50 | ls += [p] 51 | 52 | sleep(3) 53 | 54 | good_count = 0 55 | rc_ls = [] 56 | for p in ls: 57 | p.wait() 58 | if p.return_code == 0: 59 | good_count += 1 60 | rc_ls += [p.return_code] 61 | finally: 62 | for p in ls: 63 | p.stop() 64 | print(rc_ls) 65 | print(good_count) 66 | assert good_count == n 67 | 68 | 69 | @entrypoint 70 | def main(i, backend, retries): 71 | retries = int(retries) 72 | kwargs = dict() 73 | if backend == "xvnc": 74 | kwargs["rfbport"] = 42000 + 100 * worker() + int(i) 75 | # print("$DISPLAY=%s" % (os.environ.get("DISPLAY"))) 76 | d = Display(backend=backend, retries=retries, **kwargs).start() 77 | print( 78 | "my index:%s backend:%s disp:%s retries:%s" 79 | % ( 80 | i, 81 | backend, 82 | d.new_display_var, 83 | d._obj._retries_current, 84 | ) 85 | ) 86 | ok = d.is_alive() 87 | d.stop() 88 | assert ok 89 | -------------------------------------------------------------------------------- /tests/test_smart.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import pytest 5 | from easyprocess import EasyProcess 6 | 7 | from pyvirtualdisplay import Display 8 | from pyvirtualdisplay.smartdisplay import DisplayTimeoutError, SmartDisplay 9 | 10 | python = sys.executable 11 | 12 | 13 | def test_disp(): 14 | with Display(): 15 | 16 | d = SmartDisplay(visible=True).start().stop() 17 | # assert d.return_code == 0 18 | assert not d.is_alive() 19 | 20 | d = SmartDisplay(visible=False).start().stop() 21 | # assert d.return_code == 0 22 | assert not d.is_alive() 23 | 24 | 25 | def test_slowshot(): 26 | disp = SmartDisplay(visible=False).start() 27 | py = Path(__file__).parent / ("slowgui.py") 28 | proc = EasyProcess([python, py]).start() 29 | img = disp.waitgrab() 30 | proc.stop() 31 | disp.stop() 32 | assert img is not None 33 | 34 | 35 | def test_slowshot_with(): 36 | disp = SmartDisplay(visible=False) 37 | py = Path(__file__).parent / ("slowgui.py") 38 | proc = EasyProcess([python, py]) 39 | with disp: 40 | with proc: 41 | img = disp.waitgrab() 42 | assert img is not None 43 | 44 | 45 | def test_slowshot_nocrop(): 46 | disp = SmartDisplay(visible=False) 47 | py = Path(__file__).parent / ("slowgui.py") 48 | proc = EasyProcess([python, py]) 49 | with disp: 50 | with proc: 51 | img = disp.waitgrab(autocrop=False) 52 | assert img is not None 53 | 54 | 55 | def test_empty(): 56 | disp = SmartDisplay(visible=False) 57 | proc = EasyProcess([python]) 58 | with disp: 59 | with proc: 60 | with pytest.raises(Exception): 61 | disp.waitgrab(timeout=10) 62 | 63 | 64 | def test_empty_nocrop(): 65 | disp = SmartDisplay(visible=False) 66 | proc = EasyProcess([python]) 67 | with disp: 68 | with proc: 69 | with pytest.raises(Exception): 70 | disp.waitgrab(autocrop=False, timeout=10) 71 | 72 | 73 | def test_slowshot_timeout(): 74 | disp = SmartDisplay(visible=False) 75 | py = Path(__file__).parent / ("slowgui.py") 76 | proc = EasyProcess([python, py]) 77 | with disp: 78 | with proc: 79 | with pytest.raises(DisplayTimeoutError): 80 | disp.waitgrab(timeout=1) 81 | 82 | 83 | def test_slowshot_timeout_nocrop(): 84 | disp = SmartDisplay(visible=False) 85 | py = Path(__file__).parent / ("slowgui.py") 86 | proc = EasyProcess([python, py]) 87 | with disp: 88 | with proc: 89 | with pytest.raises(DisplayTimeoutError): 90 | disp.waitgrab(timeout=1, autocrop=False) 91 | -------------------------------------------------------------------------------- /tests/test_smart2.py: -------------------------------------------------------------------------------- 1 | from easyprocess import EasyProcess 2 | 3 | from pyvirtualdisplay.smartdisplay import SmartDisplay 4 | 5 | 6 | def test_double(): 7 | with SmartDisplay(visible=False, bgcolor="black") as disp: 8 | with EasyProcess(["xmessage", "hello1"]): 9 | img = disp.waitgrab() 10 | assert img is not None 11 | 12 | with SmartDisplay(visible=False, bgcolor="black") as disp: 13 | with EasyProcess(["xmessage", "hello2"]): 14 | img = disp.waitgrab() 15 | assert img is not None 16 | -------------------------------------------------------------------------------- /tests/test_smart_thread.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from threading import Thread 3 | from time import sleep 4 | 5 | from easyprocess import EasyProcess 6 | from PIL import ImageChops 7 | 8 | from pyvirtualdisplay.smartdisplay import SmartDisplay 9 | 10 | 11 | def func2(results): 12 | with SmartDisplay(manage_global_env=False) as disp: 13 | env = disp.env() 14 | logging.info("env=%s", env) 15 | sleep(2) 16 | with EasyProcess(["xmessage", "hello"], env=env): 17 | im = disp.waitgrab(timeout=1) 18 | results[0] = im 19 | sleep(2) 20 | 21 | 22 | def test_smart(): 23 | results = [None] 24 | t = Thread(target=func2, args=(results,)) 25 | t.start() 26 | sleep(1) 27 | with SmartDisplay(manage_global_env=False) as disp: 28 | env = disp.env() 29 | logging.info("env=%s", env) 30 | with EasyProcess(["xmessage", "hello"], env=env): 31 | sleep(2) 32 | im0 = disp.waitgrab(timeout=1) 33 | assert im0 34 | t.join() 35 | im1 = results[0] 36 | assert im1 37 | # im0.save('/vagrant/im0.png') 38 | # im1.save('/vagrant/im1.png') 39 | img_diff = ImageChops.difference(im0, im1) 40 | ex = img_diff.getextrema() 41 | logging.debug("diff getextrema: %s", ex) 42 | diff_bbox = img_diff.getbbox() 43 | assert diff_bbox is None 44 | -------------------------------------------------------------------------------- /tests/test_threads.py: -------------------------------------------------------------------------------- 1 | from threading import Lock, Thread 2 | from time import sleep 3 | 4 | from easyprocess import EasyProcess 5 | 6 | from pyvirtualdisplay import Display 7 | 8 | disps = [] 9 | mutex = Lock() 10 | 11 | 12 | def get_display(threadid, disp): 13 | dispnr = EasyProcess(["sh", "-c", "echo $DISPLAY"], env=disp.env()).call().stdout 14 | with mutex: 15 | disps.append((threadid, dispnr)) 16 | 17 | 18 | def func(): 19 | with Display(manage_global_env=False) as disp: 20 | get_display(1, disp) 21 | sleep(2) 22 | get_display(1, disp) 23 | 24 | 25 | def test_disp_var(): 26 | t = Thread(target=func) 27 | t.start() 28 | sleep(1) 29 | 30 | with Display(manage_global_env=False) as disp: 31 | get_display(0, disp) 32 | sleep(2) 33 | get_display(0, disp) 34 | t.join() 35 | 36 | print(disps) 37 | 38 | assert disps[0][0] == 1 39 | assert disps[1][0] == 0 40 | assert disps[2][0] == 1 41 | assert disps[3][0] == 0 42 | 43 | # :1 44 | assert disps[0][1] == disps[2][1] 45 | 46 | # :0 47 | assert disps[1][1] == disps[3][1] 48 | 49 | assert disps[0][1] != disps[1][1] 50 | -------------------------------------------------------------------------------- /tests/test_with.py: -------------------------------------------------------------------------------- 1 | from tutil import has_xvnc, rfbport 2 | 3 | from pyvirtualdisplay import Display 4 | 5 | 6 | def test_with_xvfb(): 7 | with Display(size=(800, 600)) as vd: 8 | assert vd.is_alive() 9 | assert vd._backend == "xvfb" 10 | # assert vd.return_code == 0 11 | assert not vd.is_alive() 12 | 13 | with Display(visible=False, size=(800, 600)) as vd: 14 | assert vd.is_alive() 15 | assert vd._backend == "xvfb" 16 | # assert vd.return_code == 0 17 | assert not vd.is_alive() 18 | 19 | with Display(backend="xvfb", size=(800, 600)) as vd: 20 | assert vd.is_alive() 21 | assert vd._backend == "xvfb" 22 | # assert vd.return_code == 0 23 | assert not vd.is_alive() 24 | 25 | 26 | def test_with_xephyr(): 27 | with Display() as vd: 28 | with Display(visible=True, size=(800, 600)) as vd: 29 | assert vd.is_alive() 30 | assert vd._backend == "xephyr" 31 | # assert vd.return_code == 0 32 | assert not vd.is_alive() 33 | 34 | with Display(backend="xephyr", size=(800, 600)) as vd: 35 | assert vd.is_alive() 36 | assert vd._backend == "xephyr" 37 | # assert vd.return_code == 0 38 | assert not vd.is_alive() 39 | 40 | 41 | if has_xvnc(): 42 | 43 | def test_with_xvnc(): 44 | with Display(backend="xvnc", size=(800, 600), rfbport=rfbport()) as vd: 45 | assert vd.is_alive() 46 | assert vd._backend == "xvnc" 47 | # assert vd.return_code == 0 48 | assert not vd.is_alive() 49 | 50 | 51 | def test_dpi(): 52 | with Display(backend="xvfb", size=(800, 600), dpi=99) as vd: 53 | assert vd.is_alive() 54 | # assert vd.return_code == 0 55 | assert not vd.is_alive() 56 | -------------------------------------------------------------------------------- /tests/test_xauth.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from tutil import prog_check 4 | 5 | from pyvirtualdisplay import Display, xauth 6 | 7 | if prog_check(["xauth", "-V"]): 8 | 9 | def test_xauth_is_installed(): 10 | assert xauth.is_installed() 11 | 12 | def test_xauth(): 13 | """ 14 | Test that a Xauthority file is created. 15 | """ 16 | old_xauth = os.getenv("XAUTHORITY") 17 | display = Display(visible=False, use_xauth=True) 18 | display.start() 19 | new_xauth = os.getenv("XAUTHORITY") 20 | 21 | assert new_xauth is not None 22 | assert os.path.isfile(new_xauth) 23 | filename = os.path.basename(new_xauth) 24 | assert filename.startswith("PyVirtualDisplay.") 25 | assert filename.endswith("Xauthority") 26 | 27 | display.stop() 28 | assert old_xauth == os.getenv("XAUTHORITY") 29 | assert not os.path.isfile(new_xauth) 30 | -------------------------------------------------------------------------------- /tests/test_xvnc.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from pathlib import Path 3 | 4 | from tutil import has_xvnc, has_xtightvnc, has_xtigervnc, rfbport, worker 5 | from vncdotool import api 6 | 7 | from pyvirtualdisplay import Display 8 | from pyvirtualdisplay.xvnc import XvncDisplay 9 | from pyvirtualdisplay import xvnc 10 | 11 | if has_xvnc(): 12 | 13 | def test_xvnc(): 14 | check_xvnc() 15 | 16 | 17 | if has_xtightvnc(): 18 | 19 | def test_xvnc_tight(): 20 | xvnc.PROGRAM = "Xtightvnc" 21 | check_xvnc() 22 | 23 | 24 | if has_xtigervnc(): 25 | 26 | def test_xvnc_tiger(): 27 | xvnc.PROGRAM = "Xtigervnc" 28 | check_xvnc() 29 | 30 | 31 | def check_xvnc(): 32 | with tempfile.TemporaryDirectory() as temp_dir: 33 | vnc_png = Path(temp_dir) / "vnc.png" 34 | password = "123456" 35 | passwd_file = Path(temp_dir) / "pwd.txt" 36 | vncpasswd_generated = b"\x49\x40\x15\xf9\xa3\x5e\x8b\x22" 37 | passwd_file.write_bytes(vncpasswd_generated) 38 | 39 | if worker() == 0: 40 | with Display(backend="xvnc"): 41 | with api.connect("localhost:0") as client: 42 | client.timeout = 1 43 | client.captureScreen(vnc_png) 44 | with XvncDisplay(): 45 | with api.connect("localhost:0") as client: 46 | client.timeout = 1 47 | client.captureScreen(vnc_png) 48 | 49 | sconnect = "localhost:%s" % (rfbport() - 5900) 50 | with Display(backend="xvnc", rfbport=rfbport()): 51 | with api.connect(sconnect) as client: 52 | client.timeout = 1 53 | client.captureScreen(vnc_png) 54 | with XvncDisplay(rfbport=rfbport()): 55 | with api.connect(sconnect) as client: 56 | client.timeout = 1 57 | client.captureScreen(vnc_png) 58 | 59 | with Display(backend="xvnc", rfbport=rfbport(), rfbauth=passwd_file): 60 | with api.connect(sconnect, password=password) as client: 61 | client.timeout = 1 62 | client.captureScreen(vnc_png) 63 | with XvncDisplay(rfbport=rfbport(), rfbauth=passwd_file): 64 | with api.connect(sconnect, password=password) as client: 65 | client.timeout = 1 66 | client.captureScreen(vnc_png) 67 | -------------------------------------------------------------------------------- /tests/tutil.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import psutil 5 | from easyprocess import EasyProcess 6 | 7 | from pyvirtualdisplay.util import get_helptext 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | def prog_check(cmd): 13 | try: 14 | p = EasyProcess(cmd) 15 | p.enable_stdout_log = False 16 | p.enable_stderr_log = False 17 | p.call() 18 | return p.return_code == 0 19 | except Exception: 20 | return False 21 | 22 | 23 | # def platform_is_osx(): 24 | # return sys.platform == "darwin" 25 | 26 | 27 | def has_displayfd(): 28 | return "-displayfd" in get_helptext("Xvfb") 29 | 30 | 31 | def has_xvnc(): 32 | return prog_check(["Xvnc", "-help"]) 33 | 34 | def has_xtightvnc(): 35 | return prog_check(["Xtightvnc", "-version"]) 36 | 37 | def has_xtigervnc(): 38 | return prog_check(["Xtigervnc", "-help"]) 39 | 40 | 41 | def worker(): 42 | w = 0 43 | PYTEST_XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER") 44 | if PYTEST_XDIST_WORKER: 45 | # gw42 46 | w = int(PYTEST_XDIST_WORKER[2:]) 47 | return w 48 | 49 | 50 | def rfbport(): 51 | port = 5900 + worker() + 9876 52 | log.info("rfbport=%s", port) 53 | return port 54 | 55 | 56 | def kill_process_tree(ep): 57 | parent_pid = ep.pid 58 | parent = psutil.Process(parent_pid) 59 | for child in parent.children(recursive=True): 60 | try: 61 | child.kill() 62 | except psutil.NoSuchProcess: 63 | log.warning("NoSuchProcess error in kill_process_tree") 64 | parent.kill() 65 | -------------------------------------------------------------------------------- /tests/vagrant/Vagrantfile.debian10.rb: -------------------------------------------------------------------------------- 1 | Vagrant.configure("2") do |config| 2 | config.vm.box = "debian/buster64" 3 | config.vm.boot_timeout = 600 4 | 5 | config.vm.provider "virtualbox" do |vb| 6 | # vb.gui = true 7 | vb.memory = "2048" 8 | vb.name = "pyvirtualdisplay_debian10" 9 | end 10 | 11 | config.vm.provision "shell", path: "tests/vagrant/debian10.sh", privileged: true 12 | 13 | config.ssh.extra_args = ["-t", "cd /vagrant; bash --login"] 14 | end 15 | 16 | # export VAGRANT_VAGRANTFILE=tests/vagrant/Vagrantfile.debian10.rb;export VAGRANT_DOTFILE_PATH=.vagrant_${VAGRANT_VAGRANTFILE} 17 | # vagrant up && vagrant ssh 18 | -------------------------------------------------------------------------------- /tests/vagrant/Vagrantfile.debian11.rb: -------------------------------------------------------------------------------- 1 | Vagrant.configure("2") do |config| 2 | config.vm.box = "debian/bullseye64" 3 | config.vm.boot_timeout = 600 4 | 5 | config.vm.provider "virtualbox" do |vb| 6 | # vb.gui = true 7 | vb.memory = "2048" 8 | vb.name = "pyvirtualdisplay_debian11" 9 | end 10 | 11 | config.vm.provision "shell", path: "tests/vagrant/debian11.sh", privileged: true 12 | 13 | config.ssh.extra_args = ["-t", "cd /vagrant; bash --login"] 14 | end 15 | 16 | # export VAGRANT_VAGRANTFILE=tests/vagrant/Vagrantfile.debian11.rb;export VAGRANT_DOTFILE_PATH=.vagrant_${VAGRANT_VAGRANTFILE} 17 | # vagrant up && vagrant ssh 18 | -------------------------------------------------------------------------------- /tests/vagrant/Vagrantfile.ubuntu1804.rb: -------------------------------------------------------------------------------- 1 | Vagrant.configure(2) do |config| 2 | config.vm.box = "ubuntu/bionic64" 3 | 4 | config.vm.provider "virtualbox" do |vb| 5 | vb.name = "pyvirtualdisplay_ubuntu1804" 6 | # vb.gui = true 7 | vb.memory = "2048" # ste high because of Xephyr memory leak 8 | end 9 | 10 | config.vm.provision "shell", path: "tests/vagrant/ubuntu1804.sh" 11 | 12 | config.ssh.extra_args = ["-t", "cd /vagrant; bash --login"] 13 | end 14 | 15 | # export VAGRANT_VAGRANTFILE=tests/vagrant/Vagrantfile.18.04.rb;export VAGRANT_DOTFILE_PATH=.vagrant_${VAGRANT_VAGRANTFILE} 16 | # vagrant up && vagrant ssh 17 | -------------------------------------------------------------------------------- /tests/vagrant/Vagrantfile.ubuntu2004.rb: -------------------------------------------------------------------------------- 1 | Vagrant.configure(2) do |config| 2 | config.vm.box = "ubuntu/focal64" 3 | 4 | config.vm.provider "virtualbox" do |vb| 5 | #vb.gui = true 6 | vb.memory = "2048" 7 | 8 | vb.name = "pyvirtualdisplay_ubuntu2004" 9 | end 10 | 11 | config.vm.provision "shell", path: "tests/vagrant/ubuntu2004.sh" 12 | config.ssh.extra_args = ["-t", "cd /vagrant; bash --login"] 13 | end 14 | 15 | # export VAGRANT_VAGRANTFILE=tests/vagrant/Vagrantfile.20.04.rb;export VAGRANT_DOTFILE_PATH=.vagrant_${VAGRANT_VAGRANTFILE} 16 | # vagrant up && vagrant ssh -------------------------------------------------------------------------------- /tests/vagrant/Vagrantfile.ubuntu2204.rb: -------------------------------------------------------------------------------- 1 | Vagrant.configure(2) do |config| 2 | config.vm.box = "ubuntu/jammy64" 3 | 4 | config.vm.provider "virtualbox" do |vb| 5 | vb.name = "pyvirtualdisplay_ubuntu2204" 6 | # vb.gui = true 7 | vb.memory = "2048" # ste high because of Xephyr memory leak 8 | end 9 | 10 | config.vm.provision "shell", path: "tests/vagrant/ubuntu2204.sh" 11 | 12 | config.ssh.extra_args = ["-t", "cd /vagrant; bash --login"] 13 | end 14 | 15 | # export VAGRANT_VAGRANTFILE=tests/vagrant/Vagrantfile.22.04.rb;export VAGRANT_DOTFILE_PATH=.vagrant_${VAGRANT_VAGRANTFILE} 16 | # vagrant up && vagrant ssh 17 | -------------------------------------------------------------------------------- /tests/vagrant/debian10.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export DEBIAN_FRONTEND=noninteractive 3 | sudo update-locale LANG=en_US.UTF-8 LANGUAGE=en.UTF-8 4 | # echo 'export export LC_ALL=C' >> /home/vagrant/.profile 5 | 6 | # install python versions 7 | # sudo add-apt-repository --yes ppa:deadsnakes/ppa 8 | sudo apt-get update 9 | # sudo apt-get install -y python3.6-dev 10 | # sudo apt-get install -y python3.7-dev 11 | # sudo apt-get install -y python3.8-dev 12 | # sudo apt-get install -y python3-distutils 13 | # sudo apt-get install -y python3.9-dev 14 | # sudo apt-get install -y python3.9-distutils 15 | # sudo apt-get install -y python3.10-dev 16 | # sudo apt-get install -y python3.10-distutils 17 | 18 | # tools 19 | sudo apt-get install -y mc python3-pip xvfb 20 | 21 | # for pillow source install 22 | # sudo apt-get install -y libjpeg-dev zlib1g-dev 23 | 24 | # project dependencies 25 | sudo apt-get install -y xvfb xserver-xephyr tigervnc-standalone-server tightvncserver 26 | 27 | # test dependencies 28 | sudo apt-get install -y gnumeric 29 | sudo apt-get install -y x11-utils # for: xmessage 30 | # sudo apt-get install -y x11-apps # for: xlogo 31 | sudo pip3 install tox 32 | 33 | # doc dependencies 34 | # sudo apt-get install -y npm xtightvncviewer 35 | # sudo npm install --global embedme 36 | # sudo pip install -r /vagrant/requirements-doc.txt 37 | -------------------------------------------------------------------------------- /tests/vagrant/debian11.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export DEBIAN_FRONTEND=noninteractive 3 | sudo update-locale LANG=en_US.UTF-8 LANGUAGE=en.UTF-8 4 | # echo 'export export LC_ALL=C' >> /home/vagrant/.profile 5 | 6 | # install python versions 7 | # sudo add-apt-repository --yes ppa:deadsnakes/ppa 8 | sudo apt-get update 9 | # sudo apt-get install -y python3.6-dev 10 | # sudo apt-get install -y python3.7-dev 11 | # sudo apt-get install -y python3.8-dev 12 | # sudo apt-get install -y python3-distutils 13 | # sudo apt-get install -y python3.9-dev 14 | # sudo apt-get install -y python3.9-distutils 15 | # sudo apt-get install -y python3.10-dev 16 | # sudo apt-get install -y python3.10-distutils 17 | 18 | # tools 19 | sudo apt-get install -y mc python3-pip xvfb 20 | 21 | # for pillow source install 22 | # sudo apt-get install -y libjpeg-dev zlib1g-dev 23 | 24 | # project dependencies 25 | sudo apt-get install -y xvfb xserver-xephyr tigervnc-standalone-server tightvncserver 26 | 27 | # test dependencies 28 | sudo apt-get install -y gnumeric 29 | sudo apt-get install -y x11-utils # for: xmessage 30 | # sudo apt-get install -y x11-apps # for: xlogo 31 | sudo pip3 install tox 32 | 33 | # doc dependencies 34 | # sudo apt-get install -y npm xtightvncviewer 35 | # sudo npm install --global embedme 36 | # sudo pip install -r /vagrant/requirements-doc.txt 37 | -------------------------------------------------------------------------------- /tests/vagrant/osx.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | #autologin 5 | brew tap xfreebird/utils 6 | brew install kcpassword 7 | enable_autologin "vagrant" "vagrant" 8 | 9 | # disable screensaver 10 | defaults -currentHost write com.apple.screensaver idleTime 0 11 | 12 | # Turn Off System/Display/HDD Sleep 13 | sudo systemsetup -setcomputersleep Never 14 | sudo systemsetup -setdisplaysleep Never 15 | sudo systemsetup -setharddisksleep Never 16 | 17 | #https://github.com/ponty/PyVirtualDisplay/issues/42 18 | echo "@reboot /bin/sh -c 'mkdir /tmp/.X11-unix;sudo chmod 1777 /tmp/.X11-unix;sudo chown root /tmp/.X11-unix/'" > mycron 19 | sudo crontab mycron 20 | 21 | # Error: 22 | # homebrew-core is a shallow clone. 23 | # To `brew update`, first run: 24 | git -C /usr/local/Homebrew/Library/Taps/homebrew/homebrew-core fetch --unshallow 25 | 26 | brew install openssl@1.1 27 | brew install python3 28 | brew install pidof 29 | brew install --cask xquartz 30 | # TODO: xvnc install 31 | python3 -m pip install --user pillow pytest tox 32 | 33 | # su - vagrant -c 'brew cask install xquartz' 34 | # su - vagrant -c 'python3 -m pip install --user pygame==2.0.0.dev6 pillow qtpy wxpython pyobjc-framework-Quartz pyobjc-framework-LaunchServices nose' 35 | 36 | sudo chown -R vagrant /vagrant 37 | -------------------------------------------------------------------------------- /tests/vagrant/ubuntu1804.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export DEBIAN_FRONTEND=noninteractive 3 | sudo update-locale LANG=en_US.UTF-8 LANGUAGE=en.UTF-8 4 | # echo 'export export LC_ALL=C' >> /home/vagrant/.profile 5 | 6 | # install python versions 7 | # sudo add-apt-repository --yes ppa:deadsnakes/ppa 8 | sudo apt-get update 9 | 10 | # tools 11 | sudo apt-get install -y mc python3-pip xvfb 12 | 13 | # for pillow source install 14 | # sudo apt-get install -y libjpeg-dev zlib1g-dev 15 | 16 | # project dependencies 17 | sudo apt-get install -y xvfb xserver-xephyr tigervnc-standalone-server tightvncserver 18 | 19 | # test dependencies 20 | sudo apt-get install -y gnumeric 21 | sudo apt-get install -y x11-utils # for: xmessage 22 | # sudo apt-get install -y x11-apps # for: xlogo 23 | sudo pip3 install tox 24 | 25 | # doc dependencies 26 | sudo apt-get install -y npm xtightvncviewer 27 | sudo npm install --global embedme 28 | # sudo pip install -r /vagrant/requirements-doc.txt 29 | -------------------------------------------------------------------------------- /tests/vagrant/ubuntu2004.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export DEBIAN_FRONTEND=noninteractive 3 | sudo update-locale LANG=en_US.UTF-8 LANGUAGE=en.UTF-8 4 | # echo 'export export LC_ALL=C' >> /home/vagrant/.profile 5 | 6 | # install python versions 7 | # sudo add-apt-repository --yes ppa:deadsnakes/ppa 8 | sudo apt-get update 9 | 10 | # tools 11 | sudo apt-get install -y mc python3-pip xvfb 12 | 13 | # for pillow source install 14 | # sudo apt-get install -y libjpeg-dev zlib1g-dev 15 | 16 | # project dependencies 17 | sudo apt-get install -y xvfb xserver-xephyr tigervnc-standalone-server tightvncserver 18 | 19 | # test dependencies 20 | sudo apt-get install -y gnumeric 21 | sudo apt-get install -y x11-utils # for: xmessage 22 | # sudo apt-get install -y x11-apps # for: xlogo 23 | sudo pip3 install tox 24 | 25 | # doc dependencies 26 | sudo apt-get install -y npm xtightvncviewer 27 | sudo npm install --global embedme 28 | # sudo pip install -r /vagrant/requirements-doc.txt 29 | -------------------------------------------------------------------------------- /tests/vagrant/ubuntu2204.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export DEBIAN_FRONTEND=noninteractive 3 | sudo update-locale LANG=en_US.UTF-8 LANGUAGE=en.UTF-8 4 | # echo 'export export LC_ALL=C' >> /home/vagrant/.profile 5 | 6 | # install python versions 7 | sudo add-apt-repository --yes ppa:deadsnakes/ppa 8 | sudo apt-get update 9 | 10 | sudo apt-get install -y python3.7-dev 11 | sudo apt-get install -y python3.7-distutils 12 | 13 | sudo apt-get install -y python3.8-dev 14 | sudo apt-get install -y python3.8-distutils 15 | 16 | sudo apt-get install -y python3.9-dev 17 | sudo apt-get install -y python3.9-distutils 18 | 19 | sudo apt-get install -y python3.10-dev 20 | sudo apt-get install -y python3.10-distutils 21 | 22 | sudo apt-get install -y python3.11-dev 23 | sudo apt-get install -y python3.11-distutils 24 | 25 | sudo apt-get install -y python3.12-dev 26 | sudo apt-get install -y python3.12-distutils 27 | 28 | # tools 29 | sudo apt-get install -y mc python3-pip xvfb 30 | 31 | # for pillow source install 32 | # sudo apt-get install -y libjpeg-dev zlib1g-dev 33 | 34 | # project dependencies 35 | sudo apt-get install -y xvfb xserver-xephyr tigervnc-standalone-server tightvncserver 36 | 37 | # test dependencies 38 | sudo apt-get install -y gnumeric 39 | sudo apt-get install -y x11-utils # for: xmessage 40 | # sudo apt-get install -y x11-apps # for: xlogo 41 | sudo pip3 install tox 42 | 43 | # doc dependencies 44 | sudo apt-get install -y npm xtightvncviewer 45 | sudo npm install --global embedme 46 | # sudo pip install -r /vagrant/requirements-doc.txt 47 | -------------------------------------------------------------------------------- /tests/vagrant/vagrant_boxes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | from pathlib import Path 4 | from time import sleep 5 | 6 | import fabric 7 | from entrypoint2 import entrypoint 8 | 9 | import vagrant 10 | 11 | # pip3 install fabric vncdotool python-vagrant entrypoint2 12 | 13 | DIR = Path(__file__).parent 14 | 15 | 16 | class Options: 17 | halt = True 18 | recreate = True 19 | destroy = False 20 | 21 | 22 | def run_box(options, vagrantfile, cmds): 23 | env = os.environ 24 | if vagrantfile == "Vagrantfile": 25 | env["VAGRANT_VAGRANTFILE"] = str(DIR.parent.parent / vagrantfile) 26 | env["VAGRANT_DOTFILE_PATH"] = "" 27 | else: 28 | env["VAGRANT_VAGRANTFILE"] = str(DIR / vagrantfile) 29 | env["VAGRANT_DOTFILE_PATH"] = str(DIR / (".vagrant_" + vagrantfile)) 30 | 31 | v = vagrant.Vagrant(env=env, quiet_stdout=False, quiet_stderr=False) 32 | status = v.status() 33 | state = status[0].state 34 | print(status) 35 | 36 | if options.destroy: 37 | v.halt(force=True) 38 | v.destroy() 39 | return 40 | 41 | if options.halt: 42 | v.halt() # avoid screensaver 43 | 44 | if state == "not_created": 45 | # install programs in box 46 | v.up() 47 | # restart box 48 | v.halt() 49 | 50 | try: 51 | v.up() 52 | 53 | with fabric.Connection( 54 | v.user_hostname_port(), 55 | connect_kwargs={ 56 | "key_filename": v.keyfile(), 57 | }, 58 | ) as conn: 59 | with conn.cd("c:/vagrant" if options.win else "/vagrant"): 60 | if not options.win: 61 | if options.osx: 62 | freecmd = "top -l 1 -s 0 | grep PhysMem" 63 | else: # linux 64 | freecmd = "free -h" 65 | cmds = [freecmd, "env | sort"] + cmds + [freecmd] 66 | sleep(1) 67 | for cmd in cmds: 68 | if options.recreate: 69 | if "tox" in cmd: 70 | cmd += " -r" 71 | # hangs without pty=True 72 | conn.run(cmd, echo=True, pty=True) 73 | finally: 74 | if options.halt: 75 | v.halt() 76 | 77 | 78 | config = { 79 | "ubuntu2204": ( 80 | "Vagrantfile.ubuntu2204.rb", 81 | ["tox", "PYVIRTUALDISPLAY_DISPLAYFD=0 tox"], 82 | ), 83 | "ubuntu2004": ( 84 | "Vagrantfile.ubuntu2004.rb", 85 | ["tox -e py38"], 86 | ), 87 | "ubuntu1804": ( 88 | "Vagrantfile.ubuntu1804.rb", 89 | ["tox -e py36"], 90 | ), 91 | "debian11": ( 92 | "Vagrantfile.debian11.rb", 93 | ["tox -e py39"], 94 | ), 95 | "debian10": ( 96 | "Vagrantfile.debian10.rb", 97 | ["tox -e py37"], 98 | ), 99 | # "osx": ( 100 | # "Vagrantfile.osx.rb", 101 | # [ 102 | # "bash --login -c 'python3 -m tox -e py3-osx'", 103 | # # TODO: "bash --login -c 'PYVIRTUALDISPLAY_DISPLAYFD=0 python3 -m tox -e py3-osx'", 104 | # ], 105 | # ), 106 | } 107 | 108 | 109 | @entrypoint 110 | def main(boxes="all", fast=False, destroy=False): 111 | options = Options() 112 | options.halt = not fast 113 | options.recreate = not fast 114 | options.destroy = destroy 115 | 116 | if boxes == "all": 117 | boxes = list(config.keys()) 118 | else: 119 | boxes = boxes.split(",") 120 | 121 | for k, v in config.items(): 122 | name = k 123 | vagrantfile, cmds = v[0], v[1] 124 | if name in boxes: 125 | options.win = k.startswith("win") 126 | options.osx = k.startswith("osx") 127 | print("----->") 128 | print("----->") 129 | print("-----> %s %s %s" % (name, vagrantfile, cmds)) 130 | print("----->") 131 | print("----->") 132 | try: 133 | run_box(options, vagrantfile, cmds) 134 | finally: 135 | print("<-----") 136 | print("<-----") 137 | print("<----- %s %s %s" % (name, vagrantfile, cmds)) 138 | print("<-----") 139 | print("<-----") 140 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | 2 | [tox] 3 | envlist = 4 | py312 5 | py311 6 | py310 7 | py39 8 | py38 9 | py37 10 | ; py310-doc 11 | ; py310-lint 12 | 13 | # Workaround for Vagrant 14 | #toxworkdir={toxinidir}/.tox # default 15 | toxworkdir={env:HOME}/.tox/pyvirtualdisplay 16 | 17 | [testenv] 18 | changedir=tests 19 | passenv=PYVIRTUALDISPLAY_DISPLAYFD 20 | #whitelist_externals = killall 21 | #commands_pre= 22 | # - killall Xvfb 23 | # - killall Xephyr 24 | # - killall Xvnc 25 | commands= 26 | {envpython} -m pytest -v . 27 | 28 | deps = -rrequirements-test.txt 29 | 30 | [testenv:py3-osx] 31 | changedir=tests 32 | passenv=PYVIRTUALDISPLAY_DISPLAYFD 33 | deps = -rrequirements-test.txt 34 | 35 | commands= 36 | {envpython} -m pytest -v . 37 | 38 | 39 | [testenv:py310-doc] 40 | allowlist_externals=bash 41 | changedir=doc 42 | deps = 43 | -rrequirements-doc.txt 44 | 45 | commands= 46 | bash -c "cd ..;./format-code.sh" 47 | {envpython} generate-doc.py --debug 48 | 49 | [testenv:py310-lint] 50 | allowlist_externals=bash 51 | changedir=. 52 | deps = -rrequirements-test.txt 53 | 54 | commands= 55 | bash -c "./lint.sh" 56 | --------------------------------------------------------------------------------