├── MANIFEST.in ├── .travis.yml ├── tox.ini ├── LICENSE ├── .gitignore ├── setup.py ├── test_xvfb.py ├── README.rst └── xvfbwrapper.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune * 2 | include README.rst 3 | include LICENSE 4 | include *.py 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | - "pypy" 8 | before_install: 9 | - "sudo apt-get update -qq" 10 | - "sudo apt-get install -y xvfb" 11 | script: 12 | - "python -m unittest discover" 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist=flake8,py27,py33,py34,py35,pypy 8 | 9 | [testenv] 10 | commands={envpython} -m unittest discover 11 | deps= 12 | py27,pypy: mock 13 | 14 | [testenv:flake8] 15 | basepython=python3 16 | deps=flake8 17 | commands=flake8 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | xvfbwrapper - Copyright (c) 2012-2016 Corey Goldberg 2 | 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | """distutils setup/install script for xvfbwrapper""" 5 | 6 | 7 | import os 8 | 9 | try: 10 | from setuptools import setup 11 | except ImportError: 12 | from distutils.core import setup 13 | 14 | 15 | this_dir = os.path.abspath(os.path.dirname(__file__)) 16 | with open(os.path.join(this_dir, 'README.rst')) as f: 17 | LONG_DESCRIPTION = '\n' + f.read() 18 | 19 | tests_require = [] 20 | try: 21 | from unittest import mock # noqa 22 | except ImportError: 23 | tests_require.append('mock') 24 | 25 | setup( 26 | name='xvfbwrapper', 27 | version='0.2.9.dev0', 28 | py_modules=['xvfbwrapper'], 29 | author='Corey Goldberg', 30 | author_email='cgoldberg _at_ gmail.com', 31 | description='run headless display inside X virtual framebuffer (Xvfb)', 32 | long_description=LONG_DESCRIPTION, 33 | url='https://github.com/cgoldberg/xvfbwrapper', 34 | download_url='http://pypi.python.org/pypi/xvfbwrapper', 35 | tests_require=tests_require, 36 | keywords='xvfb virtual display headless x11'.split(), 37 | license='MIT', 38 | classifiers=[ 39 | 'Operating System :: Unix', 40 | 'Operating System :: POSIX :: Linux', 41 | 'Intended Audience :: Developers', 42 | 'License :: OSI Approved :: MIT License', 43 | 'Programming Language :: Python', 44 | 'Programming Language :: Python :: 2', 45 | 'Programming Language :: Python :: 2.7', 46 | 'Programming Language :: Python :: 3', 47 | 'Programming Language :: Python :: 3.3', 48 | 'Programming Language :: Python :: 3.4', 49 | 'Programming Language :: Python :: 3.5', 50 | 'Topic :: Software Development :: Libraries :: Python Modules', 51 | ] 52 | ) 53 | -------------------------------------------------------------------------------- /test_xvfb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import unittest 6 | try: 7 | from unittest.mock import patch 8 | except ImportError: 9 | from mock import patch 10 | 11 | from xvfbwrapper import Xvfb 12 | 13 | 14 | class TestXvfb(unittest.TestCase): 15 | 16 | def reset_display(self): 17 | os.environ['DISPLAY'] = ':0' 18 | 19 | def setUp(self): 20 | self.reset_display() 21 | 22 | def test_xvfb_binary_not_exists(self): 23 | with patch('xvfbwrapper.Xvfb.xvfb_exists') as xvfb_exists: 24 | xvfb_exists.return_value = False 25 | with self.assertRaises(EnvironmentError): 26 | Xvfb() 27 | 28 | def test_start(self): 29 | xvfb = Xvfb() 30 | self.addCleanup(xvfb.stop) 31 | xvfb.start() 32 | display_var = ':{}'.format(xvfb.new_display) 33 | self.assertEqual(display_var, os.environ['DISPLAY']) 34 | self.assertIsNotNone(xvfb.proc) 35 | 36 | def test_stop(self): 37 | orig_display = os.environ['DISPLAY'] 38 | xvfb = Xvfb() 39 | xvfb.start() 40 | self.assertNotEqual(orig_display, os.environ['DISPLAY']) 41 | xvfb.stop() 42 | self.assertEqual(orig_display, os.environ['DISPLAY']) 43 | self.assertIsNone(xvfb.proc) 44 | 45 | def test_start_without_existing_display(self): 46 | del os.environ['DISPLAY'] 47 | xvfb = Xvfb() 48 | self.addCleanup(xvfb.stop) 49 | self.addCleanup(self.reset_display) 50 | xvfb.start() 51 | display_var = ':{}'.format(xvfb.new_display) 52 | self.assertEqual(display_var, os.environ['DISPLAY']) 53 | self.assertIsNotNone(xvfb.proc) 54 | 55 | def test_as_context_manager(self): 56 | orig_display = os.environ['DISPLAY'] 57 | with Xvfb() as xvfb: 58 | display_var = ':{}'.format(xvfb.new_display) 59 | self.assertEqual(display_var, os.environ['DISPLAY']) 60 | self.assertIsNotNone(xvfb.proc) 61 | self.assertEqual(orig_display, os.environ['DISPLAY']) 62 | self.assertIsNone(xvfb.proc) 63 | 64 | def test_start_with_kwargs(self): 65 | w = 800 66 | h = 600 67 | depth = 16 68 | xvfb = Xvfb(width=w, height=h, colordepth=depth) 69 | self.addCleanup(xvfb.stop) 70 | xvfb.start() 71 | self.assertEqual(w, xvfb.width) 72 | self.assertEqual(h, xvfb.height) 73 | self.assertEqual(depth, xvfb.colordepth) 74 | display_var = ':{}'.format(xvfb.new_display) 75 | self.assertEqual(display_var, os.environ['DISPLAY']) 76 | self.assertIsNotNone(xvfb.proc) 77 | 78 | def test_start_with_arbitrary_kwargs(self): 79 | xvfb = Xvfb(nolisten='tcp') 80 | self.addCleanup(xvfb.stop) 81 | xvfb.start() 82 | display_var = ':{}'.format(xvfb.new_display) 83 | self.assertEqual(display_var, os.environ['DISPLAY']) 84 | self.assertIsNotNone(xvfb.proc) 85 | 86 | def test_start_fails_with_unknown_kwargs(self): 87 | xvfb = Xvfb(foo='bar') 88 | with self.assertRaises(RuntimeError): 89 | xvfb.start() 90 | 91 | def test_get_next_unused_display_does_not_reuse_lock(self): 92 | xvfb = Xvfb() 93 | xvfb2 = Xvfb() 94 | xvfb3 = Xvfb() 95 | self.addCleanup(xvfb._cleanup_lock_file) 96 | self.addCleanup(xvfb2._cleanup_lock_file) 97 | self.addCleanup(xvfb3._cleanup_lock_file) 98 | side_effect = [11, 11, 22, 11, 22, 11, 22, 22, 22, 33] 99 | with patch('xvfbwrapper.randint', 100 | side_effect=side_effect) as mockrandint: 101 | self.assertEqual(xvfb._get_next_unused_display(), 11) 102 | self.assertEqual(mockrandint.call_count, 1) 103 | if sys.version_info >= (3, 2): 104 | with self.assertWarns(ResourceWarning): 105 | self.assertEqual(xvfb2._get_next_unused_display(), 22) 106 | self.assertEqual(mockrandint.call_count, 3) 107 | self.assertEqual(xvfb3._get_next_unused_display(), 33) 108 | self.assertEqual(mockrandint.call_count, 10) 109 | else: 110 | self.assertEqual(xvfb2._get_next_unused_display(), 22) 111 | self.assertEqual(mockrandint.call_count, 3) 112 | self.assertEqual(xvfb3._get_next_unused_display(), 33) 113 | self.assertEqual(mockrandint.call_count, 10) 114 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | xvfbwrapper 3 | =============== 4 | 5 | 6 | **Manage headless displays with Xvfb (X virtual framebuffer)** 7 | 8 | .. image:: https://travis-ci.org/cgoldberg/xvfbwrapper.svg?branch=master 9 | :target: https://travis-ci.org/cgoldberg/xvfbwrapper 10 | 11 | ---- 12 | 13 | --------- 14 | Info: 15 | --------- 16 | 17 | - Dev Home (GitHub): https://github.com/cgoldberg/xvfbwrapper 18 | - Releases (PyPI): https://pypi.python.org/pypi/xvfbwrapper 19 | - Author: `Corey Goldberg `_ - 2012-2016 20 | - License: MIT 21 | 22 | ---- 23 | 24 | --------------- 25 | About Xvfb: 26 | --------------- 27 | 28 | To run a program with a graphical display, you normally require X11 and a physical display attached. However, With Xvfb you can run headless inside a virtual dislpay. In the X Window System, X Virtual FrameBuffer (Xvfb) is an X11 server that performs all graphical operations in memory, not showing any screen output. This virtual server does not require the computer it is running on to even have a screen or any input device. Only a network layer is necessary. 29 | 30 | Xvfb is often used for running acceptance tests on a headless server. 31 | 32 | ---- 33 | 34 | ---------------------- 35 | About xvfbwrapper: 36 | ---------------------- 37 | 38 | xvfbwrapper is a small python wrapper for controlling Xvfb. It works nicely when Integrating with UI test suites in Python. 39 | 40 | ---- 41 | 42 | ---------------------------------- 43 | Install xvfbwrapper from PyPI: 44 | ---------------------------------- 45 | 46 | ``pip install xvfbwrapper`` 47 | 48 | ---- 49 | 50 | ------------------------ 51 | System Requirements: 52 | ------------------------ 53 | 54 | * Xvfb (``sudo apt-get install xvfb``, or similar) 55 | * Python 2.7 or 3.3+ 56 | 57 | ---- 58 | 59 | ++++++++++++ 60 | Examples 61 | ++++++++++++ 62 | 63 | **************** 64 | Basic Usage: 65 | **************** 66 | 67 | :: 68 | 69 | from xvfbwrapper import Xvfb 70 | 71 | vdisplay = Xvfb() 72 | vdisplay.start() 73 | 74 | # launch stuff inside 75 | # virtual display here. 76 | 77 | vdisplay.stop() 78 | 79 | ---- 80 | 81 | ********************************************* 82 | Basic Usage, specifying display geometry: 83 | ********************************************* 84 | 85 | :: 86 | 87 | from xvfbwrapper import Xvfb 88 | 89 | vdisplay = Xvfb(width=1280, height=740, colordepth=16) 90 | vdisplay.start() 91 | 92 | # launch stuff inside 93 | # virtual display here. 94 | 95 | vdisplay.stop() 96 | 97 | ---- 98 | 99 | ******************************* 100 | Usage as a Context Manager: 101 | ******************************* 102 | 103 | :: 104 | 105 | from xvfbwrapper import Xvfb 106 | 107 | with Xvfb() as xvfb: 108 | # launch stuff inside virtual display here. 109 | # It starts/stops around this code block. 110 | 111 | ---- 112 | 113 | ******************************************************* 114 | Testing Example: Headless Selenium WebDriver Tests: 115 | ******************************************************* 116 | 117 | :: 118 | 119 | import unittest 120 | 121 | from selenium import webdriver 122 | from xvfbwrapper import Xvfb 123 | 124 | 125 | class TestPages(unittest.TestCase): 126 | 127 | def setUp(self): 128 | self.xvfb = Xvfb(width=1280, height=720) 129 | self.addCleanup(self.xvfb.stop) 130 | self.xvfb.start() 131 | self.browser = webdriver.Firefox() 132 | self.addCleanup(self.browser.quit) 133 | 134 | def testUbuntuHomepage(self): 135 | self.browser.get('http://www.ubuntu.com') 136 | self.assertIn('Ubuntu', self.browser.title) 137 | 138 | def testGoogleHomepage(self): 139 | self.browser.get('http://www.google.com') 140 | self.assertIn('Google', self.browser.title) 141 | 142 | 143 | if __name__ == '__main__': 144 | unittest.main() 145 | 146 | 147 | The test class above uses `selenium` and `xvfbwrapper` to run each test case with Firefox inside a headless display. 148 | 149 | * virtual display is launched 150 | * Firefox launches inside virtual display (headless) 151 | * browser is not shown while tests are run 152 | * conditions are asserted in each test case 153 | * browser quits during cleanup 154 | * virtual display stops during cleanup 155 | 156 | *Look Ma', no browser!* 157 | 158 | (You can also take screenshots inside the virtual display for diagnosing test failures) 159 | -------------------------------------------------------------------------------- /xvfbwrapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # * Corey Goldberg, 2012, 2013, 2015, 2016 4 | # 5 | # * inspired by: PyVirtualDisplay 6 | 7 | 8 | """wrapper for running display inside X virtual framebuffer (Xvfb)""" 9 | 10 | 11 | import os 12 | import subprocess 13 | import tempfile 14 | import time 15 | import fcntl 16 | from random import randint 17 | 18 | try: 19 | BlockingIOError 20 | except NameError: 21 | # python 2 22 | BlockingIOError = IOError 23 | 24 | 25 | class Xvfb(object): 26 | 27 | # Maximum value to use for a display. 32-bit maxint is the 28 | # highest Xvfb currently supports 29 | MAX_DISPLAY = 2147483647 30 | SLEEP_TIME_BEFORE_START = 0.1 31 | 32 | def __init__(self, width=800, height=680, colordepth=24, tempdir=None, 33 | **kwargs): 34 | self.width = width 35 | self.height = height 36 | self.colordepth = colordepth 37 | self._tempdir = tempdir or tempfile.gettempdir() 38 | 39 | if not self.xvfb_exists(): 40 | msg = 'Can not find Xvfb. Please install it and try again.' 41 | raise EnvironmentError(msg) 42 | 43 | self.extra_xvfb_args = ['-screen', '0', '{}x{}x{}'.format( 44 | self.width, self.height, self.colordepth)] 45 | 46 | for key, value in kwargs.items(): 47 | self.extra_xvfb_args += ['-{}'.format(key), value] 48 | 49 | if 'DISPLAY' in os.environ: 50 | self.orig_display = os.environ['DISPLAY'].split(':')[1] 51 | else: 52 | self.orig_display = None 53 | 54 | self.proc = None 55 | 56 | def __enter__(self): 57 | self.start() 58 | return self 59 | 60 | def __exit__(self, exc_type, exc_val, exc_tb): 61 | self.stop() 62 | 63 | def start(self): 64 | self.new_display = self._get_next_unused_display() 65 | display_var = ':{}'.format(self.new_display) 66 | self.xvfb_cmd = ['Xvfb', display_var] + self.extra_xvfb_args 67 | with open(os.devnull, 'w') as fnull: 68 | self.proc = subprocess.Popen(self.xvfb_cmd, 69 | stdout=fnull, 70 | stderr=fnull, 71 | close_fds=True) 72 | # give Xvfb time to start 73 | time.sleep(self.__class__.SLEEP_TIME_BEFORE_START) 74 | ret_code = self.proc.poll() 75 | if ret_code is None: 76 | self._set_display_var(self.new_display) 77 | else: 78 | self._cleanup_lock_file() 79 | raise RuntimeError('Xvfb did not start') 80 | 81 | def stop(self): 82 | try: 83 | if self.orig_display is None: 84 | del os.environ['DISPLAY'] 85 | else: 86 | self._set_display_var(self.orig_display) 87 | if self.proc is not None: 88 | try: 89 | self.proc.terminate() 90 | self.proc.wait() 91 | except OSError: 92 | pass 93 | self.proc = None 94 | finally: 95 | self._cleanup_lock_file() 96 | 97 | def _cleanup_lock_file(self): 98 | ''' 99 | This should always get called if the process exits safely 100 | with Xvfb.stop() (whether called explicitly, or by __exit__). 101 | 102 | If you are ending up with /tmp/X123-lock files when Xvfb is not 103 | running, then Xvfb is not exiting cleanly. Always either call 104 | Xvfb.stop() in a finally block, or use Xvfb as a context manager 105 | to ensure lock files are purged. 106 | 107 | ''' 108 | self._lock_display_file.close() 109 | try: 110 | os.remove(self._lock_display_file.name) 111 | except OSError: 112 | pass 113 | 114 | def _get_next_unused_display(self): 115 | ''' 116 | In order to ensure multi-process safety, this method attempts 117 | to acquire an exclusive lock on a temporary file whose name 118 | contains the display number for Xvfb. 119 | ''' 120 | tempfile_path = os.path.join(self._tempdir, '.X{0}-lock') 121 | while True: 122 | rand = randint(1, self.__class__.MAX_DISPLAY) 123 | self._lock_display_file = open(tempfile_path.format(rand), 'w') 124 | try: 125 | fcntl.flock(self._lock_display_file, 126 | fcntl.LOCK_EX | fcntl.LOCK_NB) 127 | except BlockingIOError: 128 | continue 129 | else: 130 | return rand 131 | 132 | def _set_display_var(self, display): 133 | os.environ['DISPLAY'] = ':{}'.format(display) 134 | 135 | def xvfb_exists(self): 136 | """Check that Xvfb is available on PATH and is executable.""" 137 | paths = os.environ['PATH'].split(os.pathsep) 138 | return any(os.access(os.path.join(path, 'Xvfb'), os.X_OK) 139 | for path in paths) 140 | --------------------------------------------------------------------------------