├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── pyproject.toml ├── pytest.ini ├── sbvirtualdisplay ├── __init__.py ├── __version__.py ├── abstractdisplay.py ├── display.py ├── easyprocess.py ├── unicodeutil.py ├── xauth.py ├── xephyr.py ├── xvfb.py └── xvnc.py ├── setup.cfg ├── setup.py └── tests ├── ReadMe.md ├── pytest.ini └── test_basic_flow.py /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests, and lint with a variety of Python versions. 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: CI build 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | push: 10 | branches: 11 | - master 12 | workflow_dispatch: 13 | branches: 14 | 15 | jobs: 16 | build: 17 | 18 | env: 19 | PY_COLORS: "1" 20 | runs-on: ubuntu-latest 21 | strategy: 22 | fail-fast: false 23 | max-parallel: 6 24 | matrix: 25 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v4 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip setuptools 36 | python -m pip install flake8 pytest 37 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 38 | - name: Install sbvirtualdisplay 39 | run: | 40 | python setup.py install 41 | - name: Lint with flake8 42 | run: | 43 | pip install flake8 44 | # Stop the build if there are flake8 issues 45 | flake8 . --count --show-source --statistics --exclude=temp 46 | - name: Test with pytest 47 | run: | 48 | pytest 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Python Bytecode 2 | *.py[cod] 3 | 4 | # Packages 5 | *.egg 6 | *.egg-info 7 | dist 8 | build 9 | ghostdriver.log 10 | eggs 11 | parts 12 | bin 13 | var 14 | sdist 15 | develop-eggs 16 | .installed.cfg 17 | lib 18 | lib64 19 | __pycache__ 20 | 21 | # Python3 pyvenv 22 | .env 23 | .venv 24 | env/ 25 | venv/ 26 | ENV/ 27 | VENV/ 28 | env.bak/ 29 | venv.bak/ 30 | .sbase 31 | .sbase* 32 | seleniumbase_env 33 | seleniumbase_venv 34 | sbase_env 35 | sbase_venv 36 | pyvenv.cfg 37 | .Python 38 | include 39 | pip-delete-this-directory.txt 40 | pip-selfcheck.json 41 | ipython.1.gz 42 | nosetests.1 43 | 44 | # Installer logs 45 | pip-log.txt 46 | .swp 47 | 48 | # Unit test / coverage reports 49 | .coverage 50 | .tox 51 | coverage.xml 52 | nosetests.xml 53 | 54 | # py.test 55 | .cache/* 56 | .pytest_cache/* 57 | .pytest_config 58 | 59 | # Azure Pipelines 60 | junit 61 | test-results.xml 62 | 63 | # Developer 64 | .idea 65 | .project 66 | .pydevproject 67 | .vscode 68 | 69 | # Web Drivers 70 | chromedriver 71 | geckodriver 72 | msedgedriver 73 | operadriver 74 | MicrosoftWebDriver.exe 75 | headless_ie_selenium.exe 76 | IEDriverServer.exe 77 | chromedriver.exe 78 | geckodriver.exe 79 | msedgedriver.exe 80 | operadriver.exe 81 | 82 | # msedgedriver requirements 83 | libc++.dylib 84 | 85 | # Logs 86 | logs 87 | latest_logs 88 | log_archives 89 | archived_logs 90 | geckodriver.log 91 | pytestdebug.log 92 | 93 | # Reports 94 | latest_report 95 | report_archives 96 | archived_reports 97 | html_report.html 98 | report.html 99 | report.xml 100 | 101 | # Dashboard 102 | dashboard.html 103 | dashboard.json 104 | dash_pie.json 105 | dashboard.lock 106 | 107 | # Allure Reports / Results 108 | allure_report 109 | allure-report 110 | allure_results 111 | allure-results 112 | 113 | # Charts 114 | saved_charts 115 | 116 | # Presentations 117 | saved_presentations 118 | 119 | # Tours 120 | tours_exported 121 | 122 | # Images 123 | images_exported 124 | 125 | # Cookies 126 | saved_cookies 127 | 128 | # Recordings 129 | recordings 130 | 131 | # Automated Visual Testing 132 | visual_baseline 133 | 134 | # MkDocs WebSite Generator 135 | site/* 136 | mkdocs_build/*.md 137 | mkdocs_build/*/*.md 138 | mkdocs_build/*/*/*.md 139 | mkdocs_build/*/*/*/*.md 140 | 141 | # macOS system files 142 | .DS_Store 143 | 144 | # Other 145 | selenium-server-standalone.jar 146 | proxy.zip 147 | proxy.lock 148 | verbose_hub_server.dat 149 | verbose_node_server.dat 150 | ip_of_grid_hub.dat 151 | downloaded_files 152 | archived_files 153 | assets 154 | temp 155 | temp_*/ 156 | node_modules 157 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | 3 | (sbVirtualDisplay uses a modified version of [Flutter's Code of conduct](https://github.com/flutter/flutter/blob/master/CODE_OF_CONDUCT.md)) 4 | 5 | The sbVirtualDisplay project expects sbVirtualDisplay's contributors to act professionally and respectfully. sbVirtualDisplay contributors are expected to maintain the safety and dignity of sbVirtualDisplay's social environments. 6 | 7 | Specifically: 8 | 9 | * Respect people, their identities, their culture, and their work. 10 | * Be kind. Be courteous. Be welcoming. 11 | * Listen. Consider and acknowledge people's points before responding. 12 | 13 | Should you experience anything that makes you feel unwelcome in sbVirtualDisplay's community, please [contact us](https://gitter.im/seleniumbase/SeleniumBase). 14 | 15 | The sbVirtualDisplay project will not tolerate harassment in sbVirtualDisplay's community, even outside of sbVirtualDisplay's public communication channels. 16 | 17 | ## Questions 18 | 19 | It's always OK to ask questions. 20 | 21 | !["I try not to make fun of people for admitting they don't know things, because for each thing 'everyone knows' by the time they're adults, every day there are, on average, 10,000 people in the US hearing about it for the first time. If I make fun of people, I train them not to tell me when they have those moments. And I miss out on the fun." "Diet coke and mentos thing? What's that?" "Oh, man! We're going to the grocery store." "Why?" "You're one of today's lucky 10,000."](https://imgs.xkcd.com/comics/ten_thousand.png) 22 | 23 | Source: _[xkcd, May 2012](https://xkcd.com/1053/)_ 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Bug Reports 4 | 5 | When opening a new issue or commenting on an existing issue, please make sure to provide concise, detailed instructions on how to reproduce the issue. If the issue can't be reproduced, it will be closed. Clearly describe the results you're seeing, and the results you're expecting. 6 | 7 | ## (A Note on Style Guide Rules) 8 | 9 | [flake8](https://github.com/PyCQA/flake8) is the law of the land. The only flake8 rule ignored is [W503](https://github.com/grantmcconnaughey/Flake8Rules/blob/master/_rules/W503.md). For more details on why W503 should be ignored, see [this explanation](https://peps.python.org/pep-0008/#should-a-line-break-before-or-after-a-binary-operator), or [this shorter explanation](https://github.com/PyCQA/flake8/issues/494) by Python expert [Anthony Sottile](https://github.com/asottile). 10 | 11 | -------- 12 | 13 | For questions about this document, reach out to [Michael Mintz](https://github.com/mdmintz). 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause "Simplified" License 2 | 3 | Copyright (c) ponty and mdmintz 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, 7 | are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, 10 | this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 18 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 19 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sbVirtualDisplay (💻) [![](https://img.shields.io/pypi/v/sbvirtualdisplay.svg)](https://pypi.python.org/pypi/sbvirtualdisplay) 2 | 3 | A customized [pyvirtualdisplay](https://github.com/ponty/PyVirtualDisplay) for use with [SeleniumBase](https://github.com/seleniumbase/SeleniumBase) automation. 4 | 5 | ## Usage example: 6 | 7 | ```python 8 | from sbvirtualdisplay import Display 9 | 10 | display = Display(visible=0, size=(1440, 1880)) 11 | display.start() 12 | 13 | # Run browser tests in a headless environment 14 | 15 | display.stop() 16 | ``` 17 | 18 | ### Or as a context manager: 19 | 20 | ```python 21 | with Display(visible=0, size=(1440, 1880)): 22 | # Run browser tests in a headless environment 23 | ... 24 | ``` 25 | 26 | ## When to use: 27 | 28 | If you need to run browser tests on a headless machine (such as a Linux backend), and you can't use a browser's headless mode (such as Chrome's headless mode), then this may help. For example, Chrome does not allow extensions in headless mode, so if you need to run automated tests on a headless Linux machine and you need to use Chrome extensions, then this will let you run those tests using a virtual display. 29 | 30 | 31 | ## More info: 32 | 33 | * [Xvfb](https://en.wikipedia.org/wiki/Xvfb) is required for this to work. 34 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you've found a security vulnerability in sbVirtualDisplay, (or a dependency we use), please open an issue. 6 | 7 | [github.com/mdmintz/sbVirtualDisplay/issues](https://github.com/mdmintz/sbVirtualDisplay/issues) 8 | 9 | Please describe the results you're seeing, and the results you're expecting. 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=70.2.0", "wheel>=0.44.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "sbvirtualdisplay" 7 | readme = "README.md" 8 | dynamic = [ 9 | "urls", 10 | "version", 11 | "license", 12 | "authors", 13 | "scripts", 14 | "keywords", 15 | "classifiers", 16 | "description", 17 | "maintainers", 18 | "entry-points", 19 | "dependencies", 20 | "requires-python", 21 | "optional-dependencies", 22 | ] 23 | 24 | [tool.setuptools] 25 | packages = ["sbvirtualdisplay"] 26 | 27 | [flake8] 28 | ignore = ["W503"] 29 | 30 | [nosetests] 31 | nocapture = ["1"] 32 | logging-level = ["INFO"] 33 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | 3 | # Display console output, disable cacheprovider, and have the ipdb debugger replace pdb: 4 | addopts = --capture=no -p no:cacheprovider --pdbcls=IPython.terminal.debugger:TerminalPdb 5 | -------------------------------------------------------------------------------- /sbvirtualdisplay/__init__.py: -------------------------------------------------------------------------------- 1 | from sbvirtualdisplay.__version__ import __version__ # noqa 2 | from sbvirtualdisplay.display import Display # noqa 3 | -------------------------------------------------------------------------------- /sbvirtualdisplay/__version__.py: -------------------------------------------------------------------------------- 1 | # sbvirtualdisplay package 2 | __version__ = "1.4.0" 3 | -------------------------------------------------------------------------------- /sbvirtualdisplay/abstractdisplay.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import os 3 | import tempfile 4 | import time 5 | from threading import Lock 6 | from sbvirtualdisplay.easyprocess import EasyProcess 7 | from sbvirtualdisplay import xauth 8 | 9 | mutex = Lock() 10 | MIN_DISPLAY_NR = 1000 11 | USED_DISPLAY_NR_LIST = [] 12 | 13 | 14 | class AbstractDisplay(EasyProcess): 15 | """Common parent for Xvfb and Xephyr""" 16 | def __init__(self, use_xauth=False): 17 | mutex.acquire() 18 | try: 19 | self.display = self.search_for_display() 20 | while self.display in USED_DISPLAY_NR_LIST: 21 | self.display += 1 22 | USED_DISPLAY_NR_LIST.append(self.display) 23 | finally: 24 | mutex.release() 25 | if use_xauth and not xauth.is_installed(): 26 | raise xauth.NotFoundError() 27 | self.use_xauth = use_xauth 28 | self._old_xauth = None 29 | self._xauth_filename = None 30 | EasyProcess.__init__(self, self._cmd) 31 | 32 | @property 33 | def new_display_var(self): 34 | return ":%s" % (self.display) 35 | 36 | @property 37 | def _cmd(self): 38 | raise NotImplementedError() 39 | 40 | def lock_files(self): 41 | tmpdir = "/tmp" 42 | pattern = ".X*-lock" 43 | # remove path.py dependency 44 | names = fnmatch.filter(os.listdir(tmpdir), pattern) 45 | ls = [os.path.join(tmpdir, child) for child in names] 46 | ls = [p for p in ls if os.path.isfile(p)] 47 | return ls 48 | 49 | def search_for_display(self): 50 | # search for free display 51 | ls = [int(x.split("X")[1].split("-")[0]) for x in self.lock_files()] 52 | if len(ls): 53 | display = max(MIN_DISPLAY_NR, max(ls) + 3) 54 | else: 55 | display = MIN_DISPLAY_NR 56 | return display 57 | 58 | def redirect_display(self, on): 59 | """ 60 | on: 61 | * True -> set $DISPLAY to virtual screen 62 | * False -> set $DISPLAY to original screen 63 | :param on: bool 64 | """ 65 | d = self.new_display_var if on else self.old_display_var 66 | if d is None: 67 | del os.environ["DISPLAY"] 68 | else: 69 | os.environ["DISPLAY"] = d 70 | 71 | def start(self): 72 | """ 73 | start display 74 | :rtype: self 75 | """ 76 | if self.use_xauth: 77 | self._setup_xauth() 78 | EasyProcess.start(self) 79 | self.old_display_var = os.environ.get("DISPLAY", None) 80 | self.redirect_display(True) 81 | # wait until X server is active 82 | time.sleep(0.1) 83 | return self 84 | 85 | def stop(self): 86 | """ 87 | stop display 88 | :rtype: self 89 | """ 90 | self.redirect_display(False) 91 | EasyProcess.stop(self) 92 | if self.use_xauth: 93 | self._clear_xauth() 94 | return self 95 | 96 | def __enter__(self): 97 | """Used by the :keyword:`with` statement""" 98 | self.start() 99 | return self 100 | 101 | def __exit__(self, *exc_info): 102 | """Used by the :keyword:`with` statement""" 103 | self.stop() 104 | 105 | def _setup_xauth(self): 106 | """Set up the Xauthority file & the XAUTHORITY environment variable.""" 107 | handle, filename = tempfile.mkstemp( 108 | prefix="PyVirtualDisplay.", suffix=".Xauthority" 109 | ) 110 | self._xauth_filename = filename 111 | os.close(handle) 112 | # Save old environment 113 | self._old_xauth = {} 114 | self._old_xauth["AUTHFILE"] = os.getenv("AUTHFILE") 115 | self._old_xauth["XAUTHORITY"] = os.getenv("XAUTHORITY") 116 | 117 | os.environ["AUTHFILE"] = os.environ["XAUTHORITY"] = filename 118 | cookie = xauth.generate_mcookie() 119 | xauth.call("add", self.new_display_var, ".", cookie) 120 | 121 | def _clear_xauth(self): 122 | """Clear the Xauthority file and restore the environment variables.""" 123 | os.remove(self._xauth_filename) 124 | for varname in ["AUTHFILE", "XAUTHORITY"]: 125 | if self._old_xauth[varname] is None: 126 | del os.environ[varname] 127 | else: 128 | os.environ[varname] = self._old_xauth[varname] 129 | self._old_xauth = None 130 | -------------------------------------------------------------------------------- /sbvirtualdisplay/display.py: -------------------------------------------------------------------------------- 1 | """This module contains a customized version of pyvirtualdisplay. 2 | These helper methods SHOULD NOT be called directly from tests.""" 3 | from sbvirtualdisplay.abstractdisplay import AbstractDisplay 4 | from sbvirtualdisplay.xephyr import XephyrDisplay 5 | from sbvirtualdisplay.xvfb import XvfbDisplay 6 | from sbvirtualdisplay.xvnc import XvncDisplay 7 | 8 | 9 | class Display(AbstractDisplay): 10 | """ 11 | Common class 12 | :param color_depth: [8, 16, 24, 32] 13 | :param size: screen size (width,height) 14 | :param bgcolor: background color ['black' or 'white'] 15 | :param visible: True -> Xephyr, False -> Xvfb 16 | :param backend: 'xvfb', 'xvnc' or 'xephyr', ignores ``visible`` 17 | :param xauth: If a Xauthority file should be created. 18 | """ 19 | def __init__( 20 | self, 21 | backend=None, 22 | visible=False, 23 | size=(1024, 768), 24 | color_depth=24, 25 | bgcolor="black", 26 | use_xauth=False, 27 | **kwargs 28 | ): 29 | self.color_depth = color_depth 30 | self.size = size 31 | self.bgcolor = bgcolor 32 | self.screen = 0 33 | self.process = None 34 | self.display = None 35 | self.visible = visible 36 | self.backend = backend 37 | 38 | if not self.backend: 39 | if self.visible: 40 | self.backend = "xephyr" 41 | else: 42 | self.backend = "xvfb" 43 | 44 | self._obj = self.display_class( 45 | size=size, color_depth=color_depth, bgcolor=bgcolor, **kwargs 46 | ) 47 | AbstractDisplay.__init__(self, use_xauth=use_xauth) 48 | 49 | @property 50 | def display_class(self): 51 | assert self.backend 52 | if self.backend == "xvfb": 53 | cls = XvfbDisplay 54 | if self.backend == "xvnc": 55 | cls = XvncDisplay 56 | if self.backend == "xephyr": 57 | cls = XephyrDisplay 58 | cls.check_installed() 59 | return cls 60 | 61 | @property 62 | def _cmd(self): 63 | self._obj.display = self.display 64 | return self._obj._cmd 65 | -------------------------------------------------------------------------------- /sbvirtualdisplay/easyprocess.py: -------------------------------------------------------------------------------- 1 | """Easy to use python subprocess interface.""" 2 | from sbvirtualdisplay.unicodeutil import ( 3 | split_command, 4 | unidecode, 5 | uniencode, 6 | ) 7 | import logging 8 | import os.path 9 | import platform 10 | import signal 11 | import subprocess 12 | import tempfile 13 | import threading 14 | import time 15 | 16 | log = logging.getLogger(__name__) 17 | log.setLevel(logging.ERROR) 18 | SECTION_LINK = "link" 19 | POLL_TIME = 0.1 20 | USE_POLL = 0 21 | 22 | 23 | class EasyProcessError(Exception): 24 | def __init__(self, easy_process, msg=""): 25 | self.easy_process = easy_process 26 | self.msg = msg 27 | 28 | def __str__(self): 29 | return self.msg + " " + repr(self.easy_process) 30 | 31 | 32 | template = """cmd=%s 33 | OSError=%s 34 | Program install error! """ 35 | 36 | 37 | class EasyProcessCheckInstalledError(Exception): 38 | """This exception is raised when a process run by check() returns 39 | a non-zero exit status or OSError is raised.""" 40 | 41 | def __init__(self, easy_process): 42 | self.easy_process = easy_process 43 | 44 | def __str__(self): 45 | msg = template % ( 46 | self.easy_process.cmd, 47 | self.easy_process.oserror, 48 | ) 49 | if self.easy_process.url: 50 | msg += "\nhome page: " + self.easy_process.url 51 | if platform.dist()[0].lower() == "ubuntu": 52 | if self.easy_process.ubuntu_package: 53 | msg += "\nYou can install it in terminal:\n" 54 | msg += ( 55 | "sudo apt-get install " 56 | "%s" % self.easy_process.ubuntu_package 57 | ) 58 | return msg 59 | 60 | 61 | class EasyProcess(object): 62 | """ 63 | .. module:: easyprocess 64 | 65 | simple interface for :mod:`subprocess` 66 | 67 | :param cmd: string ('ls -l') or list of strings (['ls','-l']) 68 | :param cwd: working directory 69 | :param use_temp_files: use temp files instead of pipes for 70 | stdout and stderr, 71 | pipes can cause deadlock in some cases 72 | (see unit tests) 73 | :param env: If *env* is not ``None``, it must be a mapping that defines 74 | the environment variables for the new process; 75 | these are used instead of inheriting the current 76 | process' environment, which is the default behavior. 77 | (check :mod:`subprocess` for more information) 78 | """ 79 | def __init__( 80 | self, 81 | cmd, 82 | ubuntu_package=None, 83 | url=None, 84 | cwd=None, 85 | use_temp_files=True, 86 | env=None, 87 | ): 88 | self.use_temp_files = use_temp_files 89 | self._outputs_processed = False 90 | self.env = env 91 | self.popen = None 92 | self.stdout = None 93 | self.stderr = None 94 | self._stdout_file = None 95 | self._stderr_file = None 96 | self.url = url 97 | self.ubuntu_package = ubuntu_package 98 | self.is_started = False 99 | self.oserror = None 100 | self.cmd_param = cmd 101 | self._thread = None 102 | self._stop_thread = False 103 | self.timeout_happened = False 104 | self.cwd = cwd 105 | cmd = split_command(cmd) 106 | self.cmd = cmd 107 | self.cmd_as_string = " ".join(self.cmd) 108 | log.debug('param: "%s" ', self.cmd_param) 109 | log.debug("command: %s", self.cmd) 110 | log.debug("joined command: %s", self.cmd_as_string) 111 | if not len(cmd): 112 | raise EasyProcessError(self, "empty command!") 113 | 114 | def __repr__(self): 115 | msg = ( 116 | "<%s cmd_param=%s cmd=%s oserror=%s return_code=%s" 117 | ' stdout="%s" stderr="%s" timeout_happened=%s>' 118 | % ( 119 | self.__class__.__name__, 120 | self.cmd_param, 121 | self.cmd, 122 | self.oserror, 123 | self.return_code, 124 | self.stdout, 125 | self.stderr, 126 | self.timeout_happened, 127 | ) 128 | ) 129 | return msg 130 | 131 | @property 132 | def pid(self): 133 | """ 134 | PID (:attr:`subprocess.Popen.pid`) 135 | :rtype: int 136 | """ 137 | if self.popen: 138 | return self.popen.pid 139 | 140 | @property 141 | def return_code(self): 142 | """ 143 | returncode (:attr:`subprocess.Popen.returncode`) 144 | :rtype: int 145 | """ 146 | if self.popen: 147 | return self.popen.returncode 148 | 149 | def check(self, return_code=0): 150 | """Run command with arguments. Wait for command to complete. If the 151 | exit code was as expected and there is no exception then return, 152 | otherwise raise EasyProcessError. 153 | :param return_code: int, expected return code 154 | :rtype: self 155 | """ 156 | ret = self.call().return_code 157 | ok = ret == return_code 158 | if not ok: 159 | raise EasyProcessError( 160 | self, 161 | "check error, return code is not {0}!".format(return_code), 162 | ) 163 | return self 164 | 165 | def check_installed(self): 166 | """Used for testing if program is installed. 167 | Run command with arguments. Wait for command to complete. 168 | If OSError raised, then raise :class:`EasyProcessCheckInstalledError` 169 | with information about program installation. 170 | :param return_code: int, expected return code 171 | :rtype: self 172 | """ 173 | try: 174 | self.call() 175 | except Exception: 176 | raise EasyProcessCheckInstalledError(self) 177 | return self 178 | 179 | def call(self, timeout=None): 180 | """Run command with arguments. Wait for command to complete. 181 | same as: 182 | 1. :meth:`start` 183 | 2. :meth:`wait` 184 | 3. :meth:`stop` 185 | :rtype: self 186 | """ 187 | self.start().wait(timeout=timeout) 188 | if self.is_alive(): 189 | self.stop() 190 | return self 191 | 192 | def start(self): 193 | """start command in background and does not wait for it. 194 | :rtype: self 195 | """ 196 | if self.is_started: 197 | raise EasyProcessError(self, "process was started twice!") 198 | if self.use_temp_files: 199 | self._stdout_file = tempfile.TemporaryFile(prefix="stdout_") 200 | self._stderr_file = tempfile.TemporaryFile(prefix="stderr_") 201 | stdout = self._stdout_file 202 | stderr = self._stderr_file 203 | else: 204 | stdout = subprocess.PIPE 205 | stderr = subprocess.PIPE 206 | cmd = list(map(uniencode, self.cmd)) 207 | try: 208 | self.popen = subprocess.Popen( 209 | cmd, 210 | stdout=stdout, 211 | stderr=stderr, 212 | cwd=self.cwd, 213 | env=self.env, 214 | ) 215 | except OSError as oserror: 216 | log.debug("OSError exception: %s", oserror) 217 | self.oserror = oserror 218 | raise EasyProcessError(self, "start error") 219 | self.is_started = True 220 | log.debug("process was started (pid=%s)", self.pid) 221 | return self 222 | 223 | def is_alive(self): 224 | """ 225 | poll process using :meth:`subprocess.Popen.poll` 226 | :rtype: bool 227 | """ 228 | if self.popen: 229 | return self.popen.poll() is None 230 | else: 231 | return False 232 | 233 | def wait(self, timeout=None): 234 | if timeout is not None: 235 | if not self._thread: 236 | self._thread = threading.Thread(target=self._wait4process) 237 | self._thread.daemon = 1 238 | self._thread.start() 239 | 240 | if self._thread: 241 | self._thread.join(timeout=timeout) 242 | self.timeout_happened = ( 243 | self.timeout_happened or self._thread.isAlive() 244 | ) 245 | else: 246 | self._wait4process() 247 | return self 248 | 249 | def _wait4process(self): 250 | if self._outputs_processed: 251 | return 252 | 253 | def remove_ending_lf(s): 254 | if s.endswith("\n"): 255 | return s[:-1] 256 | else: 257 | return s 258 | 259 | if self.popen: 260 | if self.use_temp_files: 261 | if USE_POLL: 262 | while True: 263 | if self.popen.poll() is not None: 264 | break 265 | if self._stop_thread: 266 | return 267 | time.sleep(POLL_TIME) 268 | else: 269 | self.popen.wait() 270 | self._outputs_processed = True 271 | self._stdout_file.seek(0) 272 | self._stderr_file.seek(0) 273 | self.stdout = self._stdout_file.read() 274 | self.stderr = self._stderr_file.read() 275 | self._stdout_file.close() 276 | self._stderr_file.close() 277 | else: 278 | self._outputs_processed = True 279 | (self.stdout, self.stderr) = self.popen.communicate() 280 | log.debug("process has ended") 281 | self.stdout = remove_ending_lf(unidecode(self.stdout)) 282 | self.stderr = remove_ending_lf(unidecode(self.stderr)) 283 | log.debug("return code=%s", self.return_code) 284 | log.debug("stdout=%s", self.stdout) 285 | log.debug("stderr=%s", self.stderr) 286 | 287 | def stop(self): 288 | return self.sendstop().wait() 289 | 290 | def sendstop(self): 291 | """ 292 | Kill process (:meth:`subprocess.Popen.terminate`). 293 | Do not wait for command to complete. 294 | :rtype: self 295 | """ 296 | if not self.is_started: 297 | raise EasyProcessError(self, "process was not started!") 298 | 299 | log.debug('stopping process (pid=%s cmd="%s")', self.pid, self.cmd) 300 | if self.popen: 301 | if self.is_alive(): 302 | log.debug("process is active -> sending SIGTERM") 303 | try: 304 | try: 305 | self.popen.terminate() 306 | except AttributeError: 307 | os.kill(self.popen.pid, signal.SIGKILL) 308 | except OSError as oserror: 309 | log.debug("exception in terminate:%s", oserror) 310 | else: 311 | log.debug("process was already stopped") 312 | else: 313 | log.debug("process was not started") 314 | return self 315 | 316 | def sleep(self, sec): 317 | time.sleep(sec) 318 | return self 319 | 320 | def wrap(self, func, delay=0): 321 | """ 322 | returns a function which: 323 | 1. start process 324 | 2. call func, save result 325 | 3. stop process 326 | 4. returns result 327 | similar to :keyword:`with` statement 328 | :rtype: 329 | """ 330 | def wrapped(): 331 | self.start() 332 | if delay: 333 | self.sleep(delay) 334 | x = None 335 | try: 336 | x = func() 337 | except OSError as oserror: 338 | log.debug("OSError exception:%s", oserror) 339 | self.oserror = oserror 340 | raise EasyProcessError(self, "wrap error!") 341 | finally: 342 | self.stop() 343 | return x 344 | return wrapped 345 | 346 | def __enter__(self): 347 | """used by the :keyword:`with` statement""" 348 | self.start() 349 | return self 350 | 351 | def __exit__(self, *exc_info): 352 | """used by the :keyword:`with` statement""" 353 | self.stop() 354 | 355 | 356 | def extract_version(txt): 357 | """This function tries to extract the version 358 | from the help text of any program.""" 359 | words = txt.replace(",", " ").split() 360 | version = None 361 | for x in reversed(words): 362 | if len(x) > 2: 363 | if x[0].lower() == "v": 364 | x = x[1:] 365 | if "." in x and x[0].isdigit(): 366 | version = x 367 | break 368 | return version 369 | 370 | 371 | Proc = EasyProcess 372 | -------------------------------------------------------------------------------- /sbvirtualdisplay/unicodeutil.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | import sys 3 | 4 | string_types = (str,) 5 | 6 | 7 | def split_command(cmd, posix=None): 8 | """ 9 | - cmd is string list -> nothing to do 10 | - cmd is string -> split it using shlex 11 | :param cmd: string ('ls -l') or list of strings (['ls','-l']) 12 | :rtype: string list 13 | """ 14 | if not isinstance(cmd, string_types): 15 | # cmd is string list 16 | pass 17 | else: 18 | if posix is None: 19 | posix = "win" not in sys.platform 20 | cmd = shlex.split(cmd, posix=posix) 21 | return cmd 22 | 23 | 24 | def uniencode(s): 25 | return s 26 | 27 | 28 | def unidecode(s): 29 | return s.decode("utf-8", "ignore") 30 | -------------------------------------------------------------------------------- /sbvirtualdisplay/xauth.py: -------------------------------------------------------------------------------- 1 | """Utility functions for xauth.""" 2 | import os 3 | import hashlib 4 | from sbvirtualdisplay.easyprocess import EasyProcess 5 | 6 | 7 | class NotFoundError(Exception): 8 | """Error when xauth was not found.""" 9 | 10 | pass 11 | 12 | 13 | def is_installed(): 14 | """ 15 | Return whether or not xauth is installed. 16 | """ 17 | try: 18 | p = EasyProcess(["xauth", "-V"]) 19 | p.enable_stdout_log = False 20 | p.enable_stderr_log = False 21 | p.call() 22 | except Exception: 23 | return False 24 | else: 25 | return True 26 | 27 | 28 | def generate_mcookie(): 29 | """ 30 | Generate a cookie string suitable for xauth. 31 | """ 32 | data = os.urandom(16) # 16 bytes = 128 bit 33 | return hashlib.md5(data).hexdigest() 34 | 35 | 36 | def call(*args): 37 | """ 38 | Call xauth with the given args. 39 | """ 40 | EasyProcess(["xauth"] + list(args)).call() 41 | -------------------------------------------------------------------------------- /sbvirtualdisplay/xephyr.py: -------------------------------------------------------------------------------- 1 | from sbvirtualdisplay.easyprocess import EasyProcess 2 | from sbvirtualdisplay.abstractdisplay import AbstractDisplay 3 | 4 | PROGRAM = "Xephyr" 5 | 6 | 7 | class XephyrDisplay(AbstractDisplay): 8 | """ 9 | Xephyr wrapper 10 | 11 | Xephyr is an X server outputting to a window on a pre-existing X display 12 | """ 13 | 14 | def __init__(self, size=(1024, 768), color_depth=24, bgcolor="black"): 15 | """ 16 | :param bgcolor: 'black' or 'white' 17 | """ 18 | self.color_depth = color_depth 19 | self.size = size 20 | self.bgcolor = bgcolor 21 | self.screen = 0 22 | self.process = None 23 | self.display = None 24 | AbstractDisplay.__init__(self) 25 | 26 | @classmethod 27 | def check_installed(cls): 28 | p = EasyProcess([PROGRAM, "-help"]) 29 | p.enable_stdout_log = False 30 | p.enable_stderr_log = False 31 | p.call() 32 | 33 | @property 34 | def _cmd(self): 35 | cmd = [ 36 | PROGRAM, 37 | dict(black="-br", white="-wr")[self.bgcolor], 38 | "-screen", 39 | "x".join(map(str, list(self.size) + [self.color_depth])), 40 | self.new_display_var, 41 | ] 42 | return cmd 43 | -------------------------------------------------------------------------------- /sbvirtualdisplay/xvfb.py: -------------------------------------------------------------------------------- 1 | from sbvirtualdisplay.easyprocess import EasyProcess 2 | from sbvirtualdisplay.abstractdisplay import AbstractDisplay 3 | 4 | PROGRAM = "Xvfb" 5 | 6 | 7 | class XvfbDisplay(AbstractDisplay): 8 | """ 9 | Xvfb wrapper 10 | 11 | Xvfb is an X server that can run on machines with no display 12 | hardware and no physical input devices. It emulates a dumb 13 | framebuffer using virtual memory. 14 | """ 15 | 16 | def __init__( 17 | self, size=(1024, 768), color_depth=24, bgcolor="black", fbdir=None 18 | ): 19 | """ 20 | :param bgcolor: 'black' or 'white' 21 | :param fbdir: If non-null, the virtual screen is memory-mapped 22 | to a file in the given directory ('-fbdir' option) 23 | """ 24 | self.screen = 0 25 | self.size = size 26 | self.color_depth = color_depth 27 | self.process = None 28 | self.bgcolor = bgcolor 29 | self.display = None 30 | self.fbdir = fbdir 31 | AbstractDisplay.__init__(self) 32 | 33 | @classmethod 34 | def check_installed(cls): 35 | p = EasyProcess([PROGRAM, "-help"]) 36 | p.enable_stdout_log = False 37 | p.enable_stderr_log = False 38 | p.call() 39 | 40 | @property 41 | def _cmd(self): 42 | cmd = [ 43 | dict(black="-br", white="-wr")[self.bgcolor], 44 | "-nolisten", 45 | "tcp", 46 | "-screen", 47 | str(self.screen), 48 | "x".join(map(str, list(self.size) + [self.color_depth])), 49 | self.new_display_var, 50 | ] 51 | if self.fbdir: 52 | cmd += ["-fbdir", self.fbdir] 53 | return [PROGRAM] + cmd 54 | -------------------------------------------------------------------------------- /sbvirtualdisplay/xvnc.py: -------------------------------------------------------------------------------- 1 | from sbvirtualdisplay.easyprocess import EasyProcess 2 | from sbvirtualdisplay.abstractdisplay import AbstractDisplay 3 | 4 | PROGRAM = "Xvnc" 5 | 6 | 7 | class XvncDisplay(AbstractDisplay): 8 | """ 9 | Xvnc wrapper 10 | """ 11 | 12 | def __init__( 13 | self, size=(1024, 768), color_depth=24, bgcolor="black", rfbport=5900 14 | ): 15 | """ 16 | :param bgcolor: 'black' or 'white' 17 | :param rfbport: Specifies the TCP port on which Xvnc listens for 18 | connections from viewers (the protocol used in VNC is called 19 | RFB - "remote framebuffer"). 20 | The default is 5900 plus the display number. 21 | """ 22 | self.screen = 0 23 | self.size = size 24 | self.color_depth = color_depth 25 | self.process = None 26 | self.bgcolor = bgcolor 27 | self.display = None 28 | self.rfbport = rfbport 29 | AbstractDisplay.__init__(self) 30 | 31 | @classmethod 32 | def check_installed(cls): 33 | p = EasyProcess([PROGRAM, "-help"]) 34 | p.enable_stdout_log = False 35 | p.enable_stderr_log = False 36 | p.call() 37 | 38 | @property 39 | def _cmd(self): 40 | cmd = [ 41 | PROGRAM, 42 | "-depth", 43 | str(self.color_depth), 44 | "-geometry", 45 | "%dx%d" % (self.size[0], self.size[1]), 46 | "-rfbport", 47 | str(self.rfbport), 48 | self.new_display_var, 49 | ] 50 | return cmd 51 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # W503 (line break before binary operator) can be ignored. 3 | exclude=recordings,temp 4 | ignore=W503 5 | 6 | [nosetests] 7 | # nocapture=1 (Display print statements from output) 8 | # (Undo this by using: "--nologcapture") 9 | # logging-level=INFO (Shorter logs than using DEBUG) 10 | nocapture=1 11 | logging-level=INFO 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """*** sbvirtualdisplay *** 2 | A modified version of pyvirtualdisplay for optimized SeleniumBase performance. 3 | (Python 3.8+)""" 4 | from setuptools import setup 5 | import os 6 | import sys 7 | 8 | 9 | this_dir = os.path.abspath(os.path.dirname(__file__)) 10 | long_description = None 11 | total_description = None 12 | try: 13 | with open(os.path.join(this_dir, "README.md"), "rb") as f: 14 | total_description = f.read().decode("utf-8") 15 | description_lines = total_description.split("\n") 16 | long_description_lines = [] 17 | for line in description_lines: 18 | if not line.startswith(">> The Release was NOT PUBLISHED to PyPI! <<<\n") 72 | sys.exit() 73 | 74 | setup( 75 | name="sbvirtualdisplay", 76 | version=about["__version__"], 77 | description="A customized pyvirtualdisplay for SeleniumBase.", 78 | long_description=long_description, 79 | long_description_content_type="text/markdown", 80 | keywords="Xvfb Xephyr Virtual Display Linux SeleniumBase", 81 | url="https://github.com/mdmintz/sbVirtualDisplay", 82 | project_urls={ 83 | "Changelog": "https://github.com/mdmintz/sbVirtualDisplay/releases", 84 | "Download": "https://pypi.org/project/sbvirtualdisplay/#files", 85 | "SeleniumBase": "https://github.com/seleniumbase/SeleniumBase", 86 | "PyPI": "https://pypi.org/project/sbvirtualdisplay/", 87 | "Source": "https://github.com/mdmintz/sbVirtualDisplay", 88 | }, 89 | platforms=["Windows", "Linux", "Mac OS-X"], 90 | author="Michael Mintz", 91 | author_email="mdmintz@gmail.com", 92 | maintainer="Michael Mintz", 93 | license="MIT", 94 | classifiers=[ 95 | "Development Status :: 5 - Production/Stable", 96 | "Environment :: Console", 97 | "Environment :: MacOS X", 98 | "Environment :: Win32 (MS Windows)", 99 | "Environment :: Web Environment", 100 | "Framework :: Pytest", 101 | "Intended Audience :: Developers", 102 | "Intended Audience :: Information Technology", 103 | "License :: OSI Approved :: MIT License", 104 | "Operating System :: MacOS :: MacOS X", 105 | "Operating System :: Microsoft :: Windows", 106 | "Operating System :: POSIX :: Linux", 107 | "Programming Language :: Python", 108 | "Programming Language :: Python :: 3", 109 | "Programming Language :: Python :: 3.8", 110 | "Programming Language :: Python :: 3.9", 111 | "Programming Language :: Python :: 3.10", 112 | "Programming Language :: Python :: 3.11", 113 | "Programming Language :: Python :: 3.12", 114 | "Programming Language :: Python :: 3.13", 115 | "Topic :: Internet", 116 | "Topic :: Scientific/Engineering", 117 | "Topic :: Software Development", 118 | "Topic :: Software Development :: Quality Assurance", 119 | "Topic :: Software Development :: Libraries", 120 | "Topic :: Software Development :: Testing", 121 | "Topic :: Software Development :: Testing :: Acceptance", 122 | "Topic :: Software Development :: Testing :: Traffic Generation", 123 | "Topic :: Utilities", 124 | ], 125 | python_requires=">=3.8", 126 | install_requires=[], 127 | extras_require={ 128 | # pip install -e .[coverage] 129 | # Usage: coverage run -m pytest; coverage html; coverage report 130 | "coverage": [ 131 | 'coverage>=7.6.1;python_version<"3.9"', 132 | 'coverage>=7.6.9;python_version>="3.9"', 133 | 'pytest-cov>=5.0.0;python_version<"3.9"', 134 | 'pytest-cov>=6.0.0;python_version>="3.9"', 135 | ], 136 | 137 | # pip install -e .[flake8] 138 | # Usage: flake8 139 | "flake8": [ 140 | 'flake8==5.0.4;python_version<"3.9"', 141 | 'flake8==7.1.1;python_version>="3.9"', 142 | "mccabe==0.7.0", 143 | 'pyflakes==2.5.0;python_version<"3.9"', 144 | 'pyflakes==3.2.0;python_version>="3.9"', 145 | 'pycodestyle==2.9.1;python_version<"3.9"', 146 | 'pycodestyle==2.12.1;python_version>="3.9"', 147 | ], 148 | }, 149 | packages=[ 150 | "sbvirtualdisplay", 151 | ], 152 | include_package_data=True, 153 | entry_points={}, 154 | ) 155 | -------------------------------------------------------------------------------- /tests/ReadMe.md: -------------------------------------------------------------------------------- 1 | ## How to run tests 2 | 3 | ```bash 4 | python -m unittest 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | 3 | # Display console output, disable cacheprovider, and have the ipdb debugger replace pdb: 4 | addopts = --capture=no -p no:cacheprovider --pdbcls=IPython.terminal.debugger:TerminalPdb 5 | -------------------------------------------------------------------------------- /tests/test_basic_flow.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from sbvirtualdisplay import Display 3 | 4 | 5 | class Tests(TestCase): 6 | def test_unit(self): 7 | display = Display(visible=0, size=(1440, 1880)) 8 | output = display.start() 9 | print(output) 10 | self.assertEqual(output.size[0], 1440) 11 | self.assertEqual(output.size[1], 1880) 12 | self.assertEqual(output.cmd[0], "Xvfb") 13 | display.stop() 14 | --------------------------------------------------------------------------------