├── .codespellrc ├── .gitattributes ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── codespell.yml │ ├── linux.yaml │ ├── macos.yaml │ ├── pylint-linux.yaml │ ├── pylint-windows.yaml │ └── windows.yaml ├── .gitignore ├── .mailmap ├── CHANGELOG.md ├── LICENSE ├── README.md ├── command_runner ├── __init__.py ├── elevate.py └── requirements.txt ├── setup.py └── tests └── test_command_runner.py /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | # Ref: https://github.com/codespell-project/codespell#using-a-config-file 3 | skip = .git*,.codespellrc 4 | check-hidden = true 5 | # ignore-regex = 6 | # ignore-words-list = 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '37 13 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/codespell.yml: -------------------------------------------------------------------------------- 1 | # Codespell configuration is within .codespellrc 2 | --- 3 | name: Codespell 4 | 5 | on: 6 | push: 7 | branches: [master] 8 | pull_request: 9 | branches: [master] 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | codespell: 16 | name: Check for spelling errors 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Annotate locations with typos 23 | uses: codespell-project/codespell-problem-matcher@v1 24 | - name: Codespell 25 | uses: codespell-project/actions-codespell@v2 26 | -------------------------------------------------------------------------------- /.github/workflows/linux.yaml: -------------------------------------------------------------------------------- 1 | name: linux-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest] 12 | # Python 3.3 and 3.4 have been removed since github won't provide these anymore 13 | # As of 2023/01/09, we have removed python 3.5 and 3.6 as they don't work anymore with linux on github 14 | # As of 2023/08/30, we have removed python 2.7 since github actions won't provide it anymore 15 | # As of 2025/01/20, we have removed python 3.7 since github actions won't povide it anymore 16 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13", 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.10'] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v3 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install --upgrade setuptools 28 | if [ -f command_runner/requirements.txt ]; then pip install -r command_runner/requirements.txt; fi 29 | - name: Generate Report 30 | env: 31 | RUNNING_ON_GITHUB_ACTIONS: true 32 | run: | 33 | pip install pytest coverage 34 | python -m coverage run -m pytest -vvs tests 35 | - name: Upload Coverage to Codecov 36 | uses: codecov/codecov-action@v3 37 | -------------------------------------------------------------------------------- /.github/workflows/macos.yaml: -------------------------------------------------------------------------------- 1 | name: macos-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [macos-latest] 12 | # Python 3.3 and 3.4 have been removed since github won't provide these anymore 13 | # As of 2023/01/09, we have removed python 3.5 and 3.6 as they don't work anymore with linux on github 14 | # As of 2023/08/30, we have removed python 2.7 since github actions won't provide it anymore 15 | # As of 2025/01/20, we have removed python 3.7 since github actions won't povide it anymore 16 | # As of 2025/02/20, we have removed pypy-3.6 and pypy-3.7 since github actions won't povide it anymore 17 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13", 'pypy-3.8', 'pypy-3.10'] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install --upgrade setuptools 29 | if [ -f command_runner/requirements.txt ]; then pip install -r command_runner/requirements.txt; fi 30 | - name: Generate Report 31 | env: 32 | RUNNING_ON_GITHUB_ACTIONS: true 33 | run: | 34 | pip install pytest coverage 35 | python -m coverage run -m pytest -vvs tests 36 | - name: Upload Coverage to Codecov 37 | uses: codecov/codecov-action@v3 38 | -------------------------------------------------------------------------------- /.github/workflows/pylint-linux.yaml: -------------------------------------------------------------------------------- 1 | name: pylint-linux-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest] 12 | # python-version: [3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", 'pypy-3.6', 'pypy-3.7'] 13 | python-version: ["3.8", "3.12"] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | python -m pip install --upgrade setuptools 25 | if [ -f command_runner/requirements.txt ]; then pip install -r command_runner/requirements.txt; fi 26 | - name: Lint with Pylint 27 | run: | 28 | python -m pip install pylint 29 | # Do not run pylint on python 3.3 because isort is not available for python 3.3, don't run on python 3.4 because pylint: disable=xxxx does not exist 30 | # Disable E0401 import error since we lint on linux and pywin32 is obviously missing 31 | python -m pylint --disable=C,W,R,E0401 --max-line-length=127 command_runner 32 | - name: Lint with flake8 33 | run: | 34 | python -m pip install flake8 35 | # stop the build if there are Python syntax errors or undefined names 36 | python -m flake8 --count --select=E9,F63,F7,F82 --show-source --statistics command_runner 37 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics command_runner 39 | - name: Lint with Black 40 | run: | 41 | pip install black 42 | python -m black --check command_runner -------------------------------------------------------------------------------- /.github/workflows/pylint-windows.yaml: -------------------------------------------------------------------------------- 1 | name: pylint-windows-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [windows-latest] 12 | # Don't use pypy on windows since it does not have pywin32 module 13 | # python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10"] 14 | python-version: ["3.12"] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | python -m pip install --upgrade setuptools 26 | if (Test-Path "command_runner/requirements.txt") { pip install -r command_runner/requirements.txt } 27 | - name: Lint with Pylint 28 | run: | 29 | python -m pip install pylint 30 | # Do not run pylint on python 3.3 because isort is not available for python 3.3, don't run on python 3.4 because pylint: disable=xxxx does not exist 31 | python -m pylint --disable=C,W,R --max-line-length=127 command_runner 32 | - name: Lint with flake8 33 | run: | 34 | python -m pip install flake8 35 | # stop the build if there are Python syntax errors or undefined names 36 | python -m flake8 --count --select=E9,F63,F7,F82 --show-source --statistics command_runner 37 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics command_runner 39 | - name: Lint with Black 40 | run: | 41 | pip install black 42 | python -m black --check command_runner -------------------------------------------------------------------------------- /.github/workflows/windows.yaml: -------------------------------------------------------------------------------- 1 | name: windows-tests 2 | 3 | # Remember kids, the default shell here is Powershell 4 | # Don't run with python 3.3 as using python -m to run flake8 or pytest will fail. 5 | # Hence, without python -m, pytest will not have it's PYTHONPATH set to current dir and imports will fail 6 | # Don't run with python 3.4 as github cannot install it (pip install --upgrade pip fails) 7 | 8 | on: [push, pull_request] 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [windows-latest] 17 | # As of 2023/08/30, we have removed python 2.7 since github actions won't provide it anymore 18 | # As of 2024/09/15, we have removed python 3.5 since we cannot gather the dependencies anymore 19 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12", "3.13", 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.10'] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install --upgrade setuptools 31 | if (Test-Path "command_runner/requirements.txt") { pip install -r command_runner/requirements.txt } 32 | - name: Generate Report 33 | env: 34 | RUNNING_ON_GITHUB_ACTIONS: true 35 | run: | 36 | pip install pytest coverage 37 | python -m coverage run -m pytest -vvs tests 38 | - name: Upload Coverage to Codecov 39 | uses: codecov/codecov-action@v3 40 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | # PyCharm 128 | .idea/ 129 | 130 | # VSCode 131 | .vscode/ 132 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Orsiris de Jong 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v1.5.2 - Leave the queues alone 2 | 3 | - New `no_close_queues` parameter to leave stdout/stderr queues open for later usage by parent functions 4 | - Updated ofunctions.threading implementation to v2.1.0 5 | - Added python 3.12 and pypy 3.10 to test matrix 6 | - poller/monitor tests now have less rounds in pypy python implementation (takes too long on Github actions) 7 | - Various minor linter fixes 8 | 9 | # v1.5.1 - Let's get your priorities right 10 | 11 | - IO priority was set with process priority values instead of IO priority values 12 | - Failing to set process/IO priority because of insufficient permissions now shows a proper warning message in logs 13 | - priorities are now case insensitive 14 | 15 | # v1.5.0 - command and conquer them all, nod if you're happy 16 | 17 | - New silent parameter disabling all logger calls except of logging.DEBUG levels 18 | - New on_exit parameter that takes a callback function as argument 19 | - valid_exit_codes now accept boolean True which means "all" exit codes 20 | - New priority parameter 21 | - New io_priority parameter 22 | - Fix output capture failure should be an error log instead of debug 23 | - Fix no longer show debug logging for stdout or stderr when empty 24 | 25 | # v1.4.1 - command and conquer them all, don't nod 26 | 27 | - Fix endoding always was set to os default unless explicitly disabled by setting `encoding=False` 28 | 29 | # v1.4.0 - command and conquer them all 30 | 31 | ## Features 32 | 33 | - command_runner now has a `command_runner_threaded()` function which allows to run in background, but still provide live stdout/stderr stream output via queues/callbacks 34 | - Refactor poller mode to allow multiple stdout / stderr stream redirectors 35 | - Passing a queue.Queue() instance to stdout/stderr arguments will fill queue with live stream output 36 | - Passing a function to stdout/stderr arguments will callback said function with live stream output 37 | - Passing a string to stdout/stderr arguments will redirect stream into filename described by string 38 | - Added `split_stream` argument which will make command_runner return (exit_code, stdout, stderr) instead of (exit_code, output) tuple 39 | - Added `check_interval` argument which decides how much time we sleep between two checks, defaults to 0.05 seconds. 40 | Lowering this improves responsiveness, but increases CPU usage. Default value should be more than reasaonable for most applications 41 | - Added `stop_on` argument which takes a function, which is called every `check_interval` and will interrupt execution if it returns True 42 | - Added `process_callback` argument which takes a function(process), which is called upon execution with a subprocess.Popen object as argument for optional external process control 43 | - Possibility to disable command_runner stream encoding with `encoding=False` so we get raw output (bytes) 44 | - Added more unit tests (stop_on, process_callback, stream callback / queues, to_null_redirections, split_streams) 45 | 46 | ## Fixes 47 | 48 | - Fix unix command provided as list didn't work with `shell=True` 49 | - Fixed more Python 2.7 UnicodedecodeErrors on corner case exceptions catches 50 | - Fixed python 2.7 TimeoutException output can fail with UnicodedecodeError 51 | - Fix Python 2.7 does not have subprocess.DEVNULL 52 | - Ensure output is always None if process didn't return any string on stdout/stderr on Python 2.7 53 | - Fix python 2.7 process.communicate() multiple calls endup without output (non blocking process.poll() needs communicate() when using shell=True) 54 | 55 | ## Misc 56 | 57 | - Removed queue usage in monitor mode (needs lesser threads) 58 | - Optimized performance 59 | - Added new exit code -250 when queue/callbacks are used with monitor method or unknown method has been called 60 | - Optimized tests 61 | 62 | # v1.3.1 - command & conquer the standard out/err reloaded 63 | 64 | ## Misc 65 | 66 | - Packaging fixes for Python 2.7 when using `pip install command_runner` 67 | 68 | # v1.3.0 - command & conquer the standard out/err 69 | 70 | ## Features 71 | 72 | - Adds the possibility to redirect stdout/stderr to null with `stdout=False` or `stderr=False` arguments 73 | 74 | ## Misc 75 | 76 | - Add python 3.10 to the test matrix 77 | 78 | # v1.2.1 - command (threads) & conquer 79 | 80 | ## Fixes 81 | 82 | - Timeout race condition with pypy 3.7 (!) where sometimes exit code wasn't -254 83 | - Try to use signal.SIGTERM (if exists) to kill a process instead of os API that uses PID in order to prevent possible collision when process is already dead and another process with the same PID exists 84 | 85 | ## Misc 86 | 87 | - Unit tests are more verbose 88 | - Black formatter is now enforced 89 | - Timeout tests are less strict to cope with some platform delays 90 | 91 | # v1.2.0 - command (runner) & conquer 92 | 93 | ## Features 94 | 95 | - Added a new capture method (monitor) 96 | - There are now two distinct methods to capture output 97 | - Spawning a thread to enforce timeouts, and using process.communicate() (monitor method) 98 | - Spawning a thread to readlines from stdout pipe to an output queue, and reading from that output queue while enforcing timeouts (polller method) 99 | - On the fly output (live_output=True) option is now explicit (uses poller method only) 100 | - Returns partial stdout output when timeouts are reached 101 | - Returns partial stdout output when CTRL+C signal is received (only with poller method) 102 | 103 | ## Fixes 104 | 105 | - CRITICAL: Fixed rare annoying but where output wasn't complete 106 | - Use process signals in favor of direct os.kill API to avoid potential race conditions when PID is reused too fast 107 | - Allow full process subtree killing on Windows & Linux, hence not blocking multiple commands like echo "test" && sleep 100 && echo "done" 108 | - Windows does not maintain an explicit process subtree, so we runtime walk processes to establish the child processes to kill. Obviously, orphaned processes cannot be killed that way.- 109 | 110 | ## Misc 111 | 112 | - Adds a default 16K stdout buffer 113 | - Default command execution timeout is 3600s (1 hour) 114 | - Highly improved tests 115 | - All tests are done for both capture methods 116 | - Timeout tests are more accurate 117 | - Added missing encoding tests 118 | - 2500 rounds of file reading and comparison are added to detect rare queue read misses 119 | 120 | # v0.7.0 (yanked - do not use; see v1.2.0 critical fixes) - The windows GUI 121 | 122 | ## Features 123 | 124 | - Added threaded pipe reader (poller) in order to enforce timeouts on Windows GUI apps 125 | 126 | # v0.6.4 - Keep it working more 127 | 128 | ## Fixes 129 | 130 | - Fixed possible encoding issue with Python < 3.4 and powershell containing non unicode output 131 | - More packaging fixes 132 | 133 | # v0.6.3 - keep_it_working 134 | 135 | ## Fixes 136 | 137 | - Packaging fixes for Python 2.7 138 | 139 | # v0.6.2 - make_it_work 140 | 141 | ## Fixes 142 | 143 | - Improve CI tests 144 | - Fixed possible use of `windows_no_window` with Python < 3.7 should not be allowed 145 | 146 | # v0.6.0 - Initial public release - make_it_simple -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2015-2025, NetInvent, Orsiris de Jong, contact@netperfect.fr 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. 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 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # command_runner 2 | # Platform agnostic command execution, timed background jobs with live stdout/stderr output capture, and UAC/sudo elevation 3 | 4 | [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) 5 | [![Percentage of issues still open](http://isitmaintained.com/badge/open/netinvent/command_runner.svg)](http://isitmaintained.com/project/netinvent/command_runner "Percentage of issues still open") 6 | [![Maintainability](https://api.codeclimate.com/v1/badges/defbe10a354d3705f287/maintainability)](https://codeclimate.com/github/netinvent/command_runner/maintainability) 7 | [![codecov](https://codecov.io/gh/netinvent/command_runner/branch/master/graph/badge.svg?token=rXqlphOzMh)](https://codecov.io/gh/netinvent/command_runner) 8 | [![linux-tests](https://github.com/netinvent/command_runner/actions/workflows/linux.yaml/badge.svg)](https://github.com/netinvent/command_runner/actions/workflows/linux.yaml) 9 | [![windows-tests](https://github.com/netinvent/command_runner/actions/workflows/windows.yaml/badge.svg)](https://github.com/netinvent/command_runner/actions/workflows/windows.yaml) 10 | [![GitHub Release](https://img.shields.io/github/release/netinvent/command_runner.svg?label=Latest)](https://github.com/netinvent/command_runner/releases/latest) 11 | 12 | 13 | command_runner's purpose is to run external commands from python, just like subprocess on which it relies, 14 | while solving various problems a developer may face among: 15 | - Handling of all possible subprocess.popen / subprocess.check_output scenarios / python versions in one handy function without encoding / timeout hassle 16 | - Allow stdout/stderr stream output to be redirected to callback functions / output queues / files so you get to handle output in your application while commands are running 17 | - Callback to optional stop check so we can stop execution from outside command_runner 18 | - Callback with optional process information so we get to control the process from outside command_runner 19 | - Callback once we're finished to easen thread usage 20 | - Optional process priority and io_priority settings 21 | - System agnostic functionality, the developer shouldn't carry the burden of Windows & Linux differences 22 | - Optional Windows UAC elevation module compatible with CPython, PyInstaller & Nuitka 23 | - Optional Linux sudo elevation compatible with CPython, PyInstaller & Nuitka 24 | - Optional heartbeat for command execution 25 | 26 | It is compatible with Python 2.7+, tested up to Python 3.13 (backports some newer functionality to Python 3.5) and is tested on Linux, Windows and MacOS. 27 | It is also compatible with PyPy Python implementation. 28 | ...and yes, keeping Python 2.7 compatibility has proven to be quite challenging. 29 | 30 | ## command_runner 31 | 32 | command_runner is a replacement package for subprocess.popen and subprocess.check_output 33 | The main promise command_runner can do is to make sure to never have a blocking command, and always get results. 34 | 35 | It works as wrapper for subprocess.popen and subprocess.communicate that solves: 36 | - Platform differences 37 | - Handle timeouts even for windows GUI applications that don't return anything to stdout 38 | - Python language version differences 39 | - Handle timeouts even on earlier Python implementations 40 | - Handle encoding even on earlier Python implementations 41 | - Keep the promise to always return an exit code (so we don't have to deal with exit codes and exception logic at the same time) 42 | - Keep the promise to always return the command output regardless of the execution state (even with timeouts, callback interrupts and keyboard interrupts) 43 | - Can show command output on the fly without waiting the end of execution (with `live_output=True` argument) 44 | - Can give command output on the fly to application by using queues or callback functions 45 | - Catch all possible exceptions and log them properly with encoding fixes 46 | - Be compatible, and always return the same result regardless of platform 47 | 48 | command_runner also promises to properly kill commands when timeouts are reached, including spawned subprocesses of such commands. 49 | This specific behavior is achieved via psutil module, which is an optional dependency. 50 | 51 | 52 | ### command_runner in a nutshell 53 | 54 | ### In a nutshell 55 | 56 | Install with `pip install command_runner` 57 | 58 | The following example will work regardless of the host OS and the Python version. 59 | 60 | ```python 61 | from command_runner import command_runner 62 | 63 | exit_code, output = command_runner('ping 127.0.0.1', timeout=10) 64 | ``` 65 | 66 | 67 | ## Advanced command_runner usage 68 | 69 | 70 | ### Special exit codes 71 | 72 | In order to keep the promise to always provide an exit_code, special exit codes have been added for the case where none is given. 73 | Those exit codes are: 74 | 75 | - -250 : command_runner called with incompatible arguments 76 | - -251 : stop_on function returned True 77 | - -252 : KeyboardInterrupt 78 | - -253 : FileNotFoundError, OSError, IOError 79 | - -254 : Timeout 80 | - -255 : Any other uncatched exceptions 81 | 82 | This allows you to use the standard exit code logic, without having to deal with various exceptions. 83 | 84 | ### Default encoding 85 | 86 | command_runner has an `encoding` argument which defaults to `utf-8` for Unixes and `cp437` for Windows platforms. 87 | Using `cp437` ensures that most `cmd.exe` output is encoded properly, including accents and special characters, on most locale systems. 88 | Still you can specify your own encoding for other usages, like Powershell where `unicode_escape` is preferred. 89 | 90 | ```python 91 | from command_runner import command_runner 92 | 93 | command = r'C:\Windows\sysnative\WindowsPowerShell\v1.0\powershell.exe --help' 94 | exit_code, output = command_runner(command, encoding='unicode_escape') 95 | ``` 96 | 97 | Earlier subprocess.popen implementations didn't have an encoding setting so command_runner will deal with encoding for those. 98 | You can also disable command_runner's internal encoding in order to get raw process output (bytes) by passing False boolean. 99 | 100 | Example: 101 | ```python 102 | from command_runner import command_runner 103 | 104 | exit_code, raw_output = command_runner('ping 127.0.0.1', encoding=False) 105 | ``` 106 | 107 | ### On the fly (interactive screen) output 108 | 109 | **Note: for live output capture and threading, see stream redirection. If you want to run your application while command_runner gives back command output, the best way to achieve this is using queues / callbacks.** 110 | 111 | command_runner can output a command output on the fly to stdout, eg show output on screen during execution. 112 | This is helpful when the command is long, and we need to know the output while execution is ongoing. 113 | It is also helpful in order to catch partial command output when timeout is reached or a CTRL+C signal is received. 114 | Example: 115 | 116 | ```python 117 | from command_runner import command_runner 118 | 119 | exit_code, output = command_runner('ping 127.0.0.1', shell=True, live_output=True) 120 | ``` 121 | 122 | Note: using live output relies on stdout pipe polling, which has lightly higher cpu usage. 123 | 124 | ### Timeouts 125 | 126 | **command_runner has a `timeout` argument which defaults to 3600 seconds.** 127 | This default setting ensures commands will not block the main script execution. 128 | Feel free to lower / higher that setting with `timeout` argument. 129 | Note that a command_runner will try to kill the whole process tree that the command may have generated. 130 | 131 | ```python 132 | from command_runner import command_runner 133 | 134 | exit_code, output = command_runner('ping 127.0.0.1', timeout=30) 135 | ``` 136 | 137 | #### Remarks on processes termination 138 | 139 | When we instruct command_runner to stop a process (because of one of the requirements met, example timeouts), using `shell=True` will spawn a shell which will spawn the desired child process. Under MS Windows, there is no direct process tree, so we cannot easily kill the whole process tree. 140 | We fixed this by walking processes during runtime. The drawback is that orphaned processes cannot be identified this way. 141 | 142 | ### Disabling logs / silencing 143 | 144 | `command_runner` has it's own logging system, which will log all sorts of error logs. 145 | If you need to disable it's logging, just run with argument silent. 146 | Be aware that logging.DEBUG log levels won't be silenced, by design. 147 | 148 | Example: 149 | ```python 150 | from command_runner import command_runner 151 | 152 | exit_code, output = command_runner('ping 127.0.0.1', silent=True) 153 | ``` 154 | 155 | If you also need to disable logging.DEBUG level, you can run the following code which will required logging.CRITICAL only messages which `command_runner` never does: 156 | 157 | ```python 158 | import logging 159 | import command_runner 160 | 161 | logging.getLogger('command_runner').setLevel(logging.CRITICAL) 162 | ``` 163 | 164 | ### Capture method 165 | 166 | `command_runner` allows two different process output capture methods: 167 | 168 | `method='monitor'` which is default: 169 | - A thread is spawned in order to check stop conditions and kill process if needed 170 | - A main loop waits for the process to finish, then uses proc.communicate() to get it's output 171 | - Pros: 172 | - less CPU usage 173 | - less threads 174 | - Cons: 175 | - cannot read partial output on KeyboardInterrupt or stop_on (still works for partial timeout output) 176 | - cannot use queues or callback functions redirectors 177 | - is 0.1 seconds slower than poller method 178 | 179 | 180 | `method='poller'`: 181 | - A thread is spawned and reads stdout/stderr pipes into output queues 182 | - A poller loop reads from the output queues, checks stop conditions and kills process if needed 183 | - Pros: 184 | - Reads on the fly, allowing interactive commands (is also used with `live_output=True`) 185 | - Allows stdout/stderr output to be written live to callback functions, queues or files (useful when threaded) 186 | - is 0.1 seconds faster than monitor method, is preferred method for fast batch runnings 187 | - Cons: 188 | - lightly higher CPU usage 189 | 190 | Example: 191 | ```python 192 | from command_runner import command_runner 193 | 194 | 195 | exit_code, output = command_runner('ping 127.0.0.1', method='poller') 196 | exit_code, output = command_runner('ping 127.0.0.1', method='monitor') 197 | ``` 198 | 199 | #### stdout / stderr stream redirection using poller capture method 200 | 201 | command_runner can redirect the command's stdout and/or stderr streams to different outputs: 202 | - subprocess pipes 203 | - /dev/null or NUL 204 | - files 205 | - queues 206 | - callback functions 207 | 208 | Unless an output redirector is given for `stderr` argument, stderr will be redirected to `stdout` stream. 209 | Note that both queues and callback function redirectors require `poller` method and will fail if method is not set. 210 | 211 | Output redirector descriptions: 212 | 213 | - subprocess pipes 214 | 215 | - By default, stdout writes into a subprocess.PIPE which is read by command_runner and returned as `output` variable. 216 | - You may also pass any other subprocess.PIPE int values to `stdout` or `stderr` arguments. 217 | 218 | - /dev/null or NUL 219 | 220 | - If `stdout=False` and/or `stderr=False` argument(s) are given, command output will not be saved. 221 | - stdout/stderr streams will be redirected to `/dev/null` or `NUL` depending on platform. 222 | - Output will always be `None`. See `split_streams` for more details using multiple outputs. 223 | 224 | - files 225 | 226 | - Giving `stdout` and/or `stderr` arguments a string, `command_runner` will consider the string to be a file path where stream output will be written live. 227 | - Examples: 228 | ```python 229 | from command_runner import command_runner 230 | exit_code, output = command_runner('dir', stdout=r"C:/tmp/command_result", stderr=r"C:/tmp/command_error", shell=True) 231 | ``` 232 | ```python 233 | from command_runner import command_runner 234 | exit_code, output = command_runner('dir', stdout='/tmp/stdout.log', stderr='/tmp/stderr.log', shell=True) 235 | ``` 236 | - Opening a file with the wrong encoding (especially opening a CP437 encoded file on Windows with UTF-8 coded might endup with UnicodedecodeError.) 237 | 238 | - queues 239 | 240 | - Queue(s) will be filled up by command_runner. 241 | - In order to keep your program "live", we'll use the threaded version of command_runner which is basically the same except it returns a future result instead of a tuple. 242 | - Note: With all the best will, there's no good way to achieve this under Python 2.7 without using more queues, so the threaded version is only compatible with Python 3.3+. 243 | - For Python 2.7, you must create your thread and queue reader yourself (see footnote for a Python 2.7 compatible example). 244 | - Threaded command_runner plus queue example: 245 | 246 | ```python 247 | import queue 248 | from command_runner import command_runner_threaded 249 | 250 | output_queue = queue.Queue() 251 | stream_output = "" 252 | thread_result = command_runner_threaded('ping 127.0.0.1', shell=True, method='poller', stdout=output_queue) 253 | 254 | read_queue = True 255 | while read_queue: 256 | try: 257 | line = output_queue.get(timeout=0.1) 258 | except queue.Empty: 259 | pass 260 | else: 261 | if line is None: 262 | read_queue = False 263 | else: 264 | stream_output += line 265 | # ADD YOUR LIVE CODE HERE 266 | 267 | # Now we may get exit_code and output since result has become available at this point 268 | exit_code, output = thread_result.result() 269 | ``` 270 | - You might also want to read both stdout and stderr queues. In that case, you can create a read loop just like in the following example. 271 | - Here we're reading both queues in one loop, so we need to observe a couple of conditions before stopping the loop, in order to catch all queue output: 272 | ```python 273 | import queue 274 | from time import sleep 275 | from command_runner import command_runner_threaded 276 | 277 | stdout_queue = queue.Queue() 278 | stderr_queue = queue.Queue() 279 | thread_result = command_runner_threaded('ping 127.0.0.1', method='poller', shell=True, stdout=stdout_queue, stderr=stderr_queue) 280 | 281 | read_stdout = read_stderr = True 282 | while read_stdout or read_stderr: 283 | 284 | try: 285 | stdout_line = stdout_queue.get(timeout=0.1) 286 | except queue.Empty: 287 | pass 288 | else: 289 | if stdout_line is None: 290 | read_stdout = False 291 | else: 292 | print('STDOUT:', stdout_line) 293 | 294 | try: 295 | stderr_line = stderr_queue.get(timeout=0.1) 296 | except queue.Empty: 297 | pass 298 | else: 299 | if stderr_line is None: 300 | read_stderr = False 301 | else: 302 | print('STDERR:', stderr_line) 303 | 304 | # ADD YOUR LIVE CODE HERE 305 | 306 | exit_code, output = thread_result.result() 307 | assert exit_code == 0, 'We did not succeed in running the thread' 308 | 309 | ``` 310 | 311 | - callback functions 312 | 313 | - The callback function will get one argument, being a str of current stream readings. 314 | - It will be executed on every line that comes from streams. Example: 315 | ```python 316 | from command_runner import command_runner 317 | 318 | def callback_function(string): 319 | # ADD YOUR CODE HERE 320 | print('CALLBACK GOT:', string) 321 | 322 | # Launch command_runner 323 | exit_code, output = command_runner('ping 127.0.0.1', stdout=callback_function, method='poller') 324 | ``` 325 | 326 | ### stdin stream redirection 327 | 328 | `command_runner` allows to redirect some stream directly into the subprocess it spawns. 329 | 330 | Example code 331 | ```python 332 | import sys 333 | from command_runner import command_runner 334 | 335 | 336 | exit_code, output = command_runner("gzip -d", stdin=sys.stdin.buffer) 337 | print("Uncompressed data", output) 338 | ``` 339 | The above program, when run with `echo "Hello, World!" | gzip | python myscript.py` will show the uncompressed string `Hello, World!` 340 | 341 | You can use whatever file descriptor you want, basic ones being sys.stdin for text input and sys.stdin.buffer for binary input. 342 | 343 | ### Checking intervals 344 | 345 | By default, command_runner checks timeouts and outputs every 0.05 seconds. 346 | You can increase/decrease this setting via `check_interval` setting which accepts floats. 347 | Example: `command_runner(cmd, check_interval=0.2)` 348 | Note that lowering `check_interval` will increase CPU usage. 349 | 350 | ### stop_on 351 | 352 | In some situations, you want a command to be aborted on some external triggers. 353 | That's where `stop_on` argument comes in handy, 354 | Just pass a function to `stop_on`, which will be executed every on every `check_interval`. As soon as function result becomes True, execution will halt with exit code -251. 355 | 356 | As a side note, when using `stop_on=my_func`, if `my_func` is cpu/io intensive, you should set `check_interval` to something reasonable, which generally counts in seconds. 357 | 358 | Example: 359 | ```python 360 | from command_runner import command_runner 361 | 362 | def some_function(): 363 | return True if we_must_stop_execution 364 | exit_code, output = command_runner('ping 127.0.0.1', stop_on=some_function, check_interval=2) 365 | ``` 366 | 367 | ### Getting current process information 368 | 369 | `command_runner` can provide an instance of subprocess.Popen of currently run command as external data, in order to retrieve process data like pids. 370 | In order to do so, just declare a function and give it as `process_callback` argument. 371 | 372 | Example: 373 | ```python 374 | from command_runner import command_runner 375 | 376 | def show_process_info(process): 377 | print('My process has pid: {}'.format(process.pid)) 378 | 379 | exit_code, output = command_runner('ping 127.0.0.1', process_callback=show_process_info) 380 | ``` 381 | 382 | ### Split stdout and stderr 383 | 384 | By default, `command_runner` returns a tuple like `(exit_code, output)` in which output contains both stdout and stderr stream outputs. 385 | You can alter that behavior by using argument `split_stream=True`. 386 | In that case, `command_runner` will return a tuple like `(exit_code, stdout, stderr)`. 387 | 388 | Example: 389 | ```python 390 | from command_runner import command_runner 391 | 392 | exit_code, stdout, stderr = command_runner('ping 127.0.0.1', split_streams=True) 393 | print('exit code:', exit_code) 394 | print('stdout', stdout) 395 | print('stderr', stderr) 396 | ``` 397 | 398 | ### On-exit Callback 399 | 400 | `command_runner` allows to execute a callback function once it has finished it's execution. 401 | This might help building threaded programs where a callback is needed to disable GUI elements for example, or make the program aware that execution has finished without the need for polling checks. 402 | 403 | Example: 404 | ```python 405 | from command_runner import command_runner 406 | 407 | def do_something(): 408 | print("We're done running") 409 | 410 | exit_code, output = command_runner('ping 127.0.0.1', on_exit=do_something) 411 | ``` 412 | 413 | ### Process and IO priority 414 | `command_runner` can set it's subprocess priority to 'low', 'normal' or 'high', which translate to 15, 0, -15 niceness on Linux and BELOW_NORMAL_PRIORITY_CLASS and HIGH_PRIORITY_CLASS in Windows. 415 | On Linux, you may also directly use priority with niceness int values. 416 | 417 | You may also set subprocess io priority to 'low', 'normal' or 'high'. 418 | 419 | Example: 420 | ```python 421 | from command_runner import command_runner 422 | 423 | exit_code, output = command_runner('some_intensive_process', priority='low', io_priority='high') 424 | ``` 425 | 426 | ### Heartbeat 427 | When running long commands, one might want to know that the program is still running. 428 | The following example will log a message every hour stating that we're still running our command 429 | 430 | ```python 431 | from command_runner import command_runner 432 | 433 | exit_code, output = command_runner('/some/long/command', timeout=None, heartbeat=3600) 434 | ``` 435 | 436 | ### Other arguments 437 | 438 | `command_runner` takes **any** argument that `subprocess.Popen()` would take. 439 | 440 | It also uses the following standard arguments: 441 | - command (str/list): The command, doesn't need to be a list, a simple string works 442 | - valid_exit_codes (list): List of exit codes which won't trigger error logs 443 | - timeout (int): seconds before a process tree is killed forcefully, defaults to 3600 444 | - shell (bool): Shall we use the cmd.exe or /usr/bin/env shell for command execution, defaults to False 445 | - encoding (str/bool): Which text encoding the command produces, defaults to cp437 under Windows and utf-8 under Linux 446 | - stdin (sys.stdin/int): Optional stdin file descriptor, sent to the process command_runner spawns 447 | - stdout (str/queue.Queue/function/False/None): Optional path to filename where to dump stdout, or queue where to write stdout, or callback function which is called when stdout has output 448 | - stderr (str/queue.Queue/function/False/None): Optional path to filename where to dump stderr, or queue where to write stderr, or callback function which is called when stderr has output 449 | - no_close_queues (bool): Normally, command_runner sends None to stdout / stderr queues when process is finished. This behavior can be disabled allowing to reuse those queues for other functions wrapping command_runner 450 | - windows_no_window (bool): Shall a command create a console window (MS Windows only), defaults to False 451 | - live_output (bool): Print output to stdout while executing command, defaults to False 452 | - method (str): Accepts 'poller' or 'monitor' stdout capture and timeout monitoring methods 453 | - check interval (float): Defaults to 0.05 seconds, which is the time between stream readings and timeout checks 454 | - stop_on (function): Optional function that when returns True stops command_runner execution 455 | - on_exit (function): Optional function that gets executed when command_runner has finished (callback function) 456 | - process_callback (function): Optional function that will take command_runner spawned process as argument, in order to deal with process info outside of command_runner 457 | - split_streams (bool): Split stdout and stderr into two separate results 458 | - silent (bool): Allows to disable command_runner's internal logs, except for logging.DEBUG levels which for obvious reasons should never be silenced 459 | - priority (str): Allows to set CPU bound process priority (takes 'low', 'normal' or 'high' parameter) 460 | - io_priority (str): Allows to set IO priority for process (takes 'low', 'normal' or 'high' parameter) 461 | - heartbeat (int): Optional seconds on which command runner should log a heartbeat message 462 | - close_fds (bool): Like Popen, defaults to True on Linux and False on Windows 463 | - universal_newlines (bool): Like Popen, defaults to False 464 | - creation_flags (int): Like Popen, defaults to 0 465 | - bufsize (int): Like Popen, defaults to 16384. Line buffering (bufsize=1) is deprecated since Python 3.7 466 | 467 | **Note that ALL other subprocess.Popen arguments are supported, since they are directly passed to subprocess.** 468 | 469 | 470 | ### command_runner Python 2.7 compatible queue reader 471 | 472 | The following example is a Python 2.7 compatible threaded implementation that reads stdout / stderr queue in a thread. 473 | This only exists for compatibility reasons. 474 | 475 | ```python 476 | import queue 477 | import threading 478 | from command_runner import command_runner 479 | 480 | def read_queue(output_queue): 481 | """ 482 | Read the queue as thread 483 | Our problem here is that the thread can live forever if we don't check a global value, which is...well ugly 484 | """ 485 | stream_output = "" 486 | read_queue = True 487 | while read_queue: 488 | try: 489 | line = output_queue.get(timeout=1) 490 | except queue.Empty: 491 | pass 492 | else: 493 | # The queue reading can be stopped once 'None' is received. 494 | if line is None: 495 | read_queue = False 496 | else: 497 | stream_output += line 498 | # ADD YOUR LIVE CODE HERE 499 | 500 | 501 | # Create a new queue that command_runner will fill up 502 | output_queue = queue.Queue() 503 | 504 | # Create a thread of read_queue() in order to read the queue while command_runner executes the command 505 | read_thread = threading.Thread( 506 | target=read_queue, args=(output_queue) 507 | ) 508 | read_thread.daemon = True # thread dies with the program 509 | read_thread.start() 510 | 511 | # Launch command_runner, which will be blocking. Your live code goes directly into the threaded function 512 | exit_code, output = command_runner('ping 127.0.0.1', stdout=output_queue, method='poller') 513 | ``` 514 | 515 | # UAC Elevation / sudo elevation 516 | 517 | command_runner package allowing privilege elevation. 518 | Becoming an admin is fairly easy with command_runner.elevate 519 | You only have to import the elevate module, and then launch your main function with the elevate function. 520 | 521 | ### elevation In a nutshell 522 | 523 | ```python 524 | from command_runner.elevate import elevate 525 | 526 | def main(): 527 | """My main function that should be elevated""" 528 | print("Who's the administrator, now ?") 529 | 530 | if __name__ == '__main__': 531 | elevate(main) 532 | ``` 533 | 534 | elevate function handles arguments (positional and keyword arguments). 535 | `elevate(main, arg, arg2, kw=somearg)` will call `main(arg, arg2, kw=somearg)` 536 | 537 | ### Advanced elevate usage 538 | 539 | #### is_admin() function 540 | 541 | The elevate module has a nifty is_admin() function that returns a boolean according to your current root/administrator privileges. 542 | Usage: 543 | 544 | ```python 545 | from command_runner.elevate import is_admin 546 | 547 | print('Am I an admin ? %s' % is_admin()) 548 | ``` 549 | 550 | #### sudo elevation 551 | 552 | Initially designed for Windows UAC, command_runner.elevate can also elevate privileges on Linux, using the sudo command. 553 | This is mainly designed for PyInstaller / Nuitka executables, as it's really not safe to allow automatic privilege elevation of a Python interpreter. 554 | 555 | Example for a binary in `/usr/local/bin/my_compiled_python_binary` 556 | 557 | You'll have to allow this file to be run with sudo without a password prompt. 558 | This can be achieved in `/etc/sudoers` file. 559 | 560 | Example for Redhat / Rocky Linux, where adding the following line will allow the elevation process to succeed without password: 561 | ``` 562 | someuser ALL= NOPASSWD:/usr/local/bin/my_compiled_python_binary 563 | ``` 564 | -------------------------------------------------------------------------------- /command_runner/__init__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This file is part of command_runner module 5 | 6 | """ 7 | command_runner is a quick tool to launch commands from Python, get exit code 8 | and output, and handle most errors that may happen 9 | 10 | Versioning semantics: 11 | Major version: backward compatibility breaking changes 12 | Minor version: New functionality 13 | Patch version: Backwards compatible bug fixes 14 | 15 | """ 16 | # python 2.7 compat fixes so all strings are considered unicode 17 | from __future__ import unicode_literals 18 | 19 | 20 | __intname__ = "command_runner" 21 | __author__ = "Orsiris de Jong" 22 | __copyright__ = "Copyright (C) 2015-2025 Orsiris de Jong for NetInvent" 23 | __licence__ = "BSD 3 Clause" 24 | __version__ = "1.7.4" 25 | __build__ = "2025052301" 26 | __compat__ = "python2.7+" 27 | 28 | import io 29 | import os 30 | import shlex 31 | import subprocess 32 | import sys 33 | from datetime import datetime 34 | from logging import getLogger 35 | from time import sleep 36 | 37 | 38 | # Avoid checking os type numerous times 39 | os_name = os.name 40 | 41 | 42 | # Don't bother with an ImportError since we need command_runner to work without dependencies 43 | try: 44 | import psutil 45 | 46 | # Also make sure we directly import priority classes so we can reuse them 47 | if os_name == "nt": 48 | from psutil import ( 49 | # ABOVE_NORMAL_PRIORITY_CLASS, 50 | BELOW_NORMAL_PRIORITY_CLASS, 51 | HIGH_PRIORITY_CLASS, 52 | IDLE_PRIORITY_CLASS, 53 | NORMAL_PRIORITY_CLASS, 54 | REALTIME_PRIORITY_CLASS, 55 | ) 56 | from psutil import ( 57 | IOPRIO_HIGH, 58 | IOPRIO_NORMAL, 59 | IOPRIO_LOW, 60 | # IOPRIO_VERYLOW, 61 | ) 62 | else: 63 | from psutil import ( 64 | IOPRIO_CLASS_BE, 65 | IOPRIO_CLASS_IDLE, 66 | # IOPRIO_CLASS_NONE, 67 | IOPRIO_CLASS_RT, 68 | ) 69 | except (ImportError, AttributeError): 70 | if os_name == "nt": 71 | BELOW_NORMAL_PRIORITY_CLASS = 16384 72 | HIGH_PRIORITY_CLASS = 128 73 | NORMAL_PRIORITY_CLASS = 32 74 | REALTIME_PRIORITY_CLASS = 256 75 | IDLE_PRIORITY_CLASS = 64 76 | IOPRIO_HIGH = 3 77 | IOPRIO_NORMAL = 2 78 | IOPRIO_LOW = 1 79 | else: 80 | IOPRIO_CLASS_IDLE = 3 81 | IOPRIO_CLASS_BE = 2 82 | IOPRIO_CLASS_RT = 1 83 | 84 | 85 | # Python 2.7 does not have priorities defined in subprocess module, but psutil has 86 | # Since Windows and Linux use different possible values, let's simplify things by 87 | # allowing 5 process priorities: verylow (idle), low, normal, high, rt 88 | # and 3 process io priorities: low, normal, high 89 | # For IO, rt == high 90 | if os_name == "nt": 91 | PRIORITIES = { 92 | "process": { 93 | "verylow": IDLE_PRIORITY_CLASS, 94 | "low": BELOW_NORMAL_PRIORITY_CLASS, 95 | "normal": NORMAL_PRIORITY_CLASS, 96 | "high": HIGH_PRIORITY_CLASS, 97 | "rt": REALTIME_PRIORITY_CLASS, 98 | }, 99 | "io": { 100 | "low": IOPRIO_LOW, 101 | "normal": IOPRIO_NORMAL, 102 | "high": IOPRIO_HIGH, 103 | }, 104 | } 105 | else: 106 | PRIORITIES = { 107 | "process": { 108 | "verylow": 20, 109 | "low": 15, 110 | "normal": 0, 111 | "high": -15, 112 | "rt": -20, 113 | }, 114 | "io": { 115 | "low": IOPRIO_CLASS_IDLE, 116 | "normal": IOPRIO_CLASS_BE, 117 | "high": IOPRIO_CLASS_RT, 118 | }, 119 | } 120 | 121 | 122 | try: 123 | import signal 124 | except ImportError: 125 | pass 126 | 127 | # Python 2.7 compat fixes (queue was Queue) 128 | try: 129 | import queue 130 | except ImportError: 131 | import Queue as queue 132 | import threading 133 | 134 | # Python 2.7 compat fixes (missing typing) 135 | try: 136 | from typing import Union, Optional, List, Tuple, Any, Callable 137 | except ImportError: 138 | pass 139 | 140 | # Python 2.7 compat fixes (no concurrent futures) 141 | try: 142 | from concurrent.futures import Future 143 | from functools import wraps 144 | except ImportError: 145 | # Python 2.7 just won't have concurrent.futures, so we just declare threaded and wraps in order to 146 | # avoid NameError 147 | def threaded(fn): 148 | """ 149 | Simple placeholder for python 2.7 150 | """ 151 | return fn 152 | 153 | def wraps(fn): 154 | """ 155 | Simple placeholder for python 2.7 156 | """ 157 | return fn 158 | 159 | 160 | # Python 2.7 compat fixes (no FileNotFoundError class) 161 | try: 162 | # pylint: disable=E0601 (used-before-assignment) 163 | FileNotFoundError 164 | except NameError: 165 | # pylint: disable=W0622 (redefined-builtin) 166 | FileNotFoundError = IOError 167 | 168 | # python <= 3.3 compat fixes (missing TimeoutExpired class) 169 | try: 170 | TimeoutExpired = subprocess.TimeoutExpired 171 | except AttributeError: 172 | 173 | class TimeoutExpired(BaseException): 174 | """ 175 | Basic redeclaration when subprocess.TimeoutExpired does not exist, python <= 3.3 176 | """ 177 | 178 | def __init__(self, cmd, timeout, output=None, stderr=None, *args, **kwargs): 179 | self.cmd = cmd 180 | self.timeout = timeout 181 | self.output = output 182 | self.stderr = stderr 183 | try: 184 | super().__init__(*args, **kwargs) 185 | except TypeError: 186 | # python 2.7 needs super(Baseclass, self) 187 | super(BaseException, self).__init__(*args, **kwargs) 188 | 189 | def __str__(self): 190 | return "Command '%s' timed out after %s seconds" % (self.cmd, self.timeout) 191 | 192 | @property 193 | def stdout(self): 194 | return self.output 195 | 196 | @stdout.setter 197 | def stdout(self, value): 198 | # There's no obvious reason to set this, but allow it anyway so 199 | # .stdout is a transparent alias for .output 200 | self.output = value 201 | 202 | 203 | class InterruptGetOutput(BaseException): 204 | """ 205 | Make sure we get the current output when process is stopped mid-execution 206 | """ 207 | 208 | def __init__(self, output, *args, **kwargs): 209 | self._output = output 210 | try: 211 | super().__init__(*args, **kwargs) 212 | except TypeError: 213 | # python 2.7 needs super(Baseclass, self) 214 | super(BaseException, self).__init__(*args, **kwargs) 215 | 216 | @property 217 | def output(self): 218 | return self._output 219 | 220 | 221 | class KbdInterruptGetOutput(InterruptGetOutput): 222 | """ 223 | Make sure we get the current output when KeyboardInterrupt is made 224 | """ 225 | 226 | def __init__(self, output, *args, **kwargs): 227 | self._output = output 228 | try: 229 | super().__init__(*args, **kwargs) 230 | except TypeError: 231 | # python 2.7 needs super(Baseclass, self) 232 | super(InterruptGetOutput, self).__init__(*args, **kwargs) 233 | 234 | @property 235 | def output(self): 236 | return self._output 237 | 238 | 239 | class StopOnInterrupt(InterruptGetOutput): 240 | """ 241 | Make sure we get the current output when optional stop_on function execution returns True 242 | """ 243 | 244 | def __init__(self, output, *args, **kwargs): 245 | self._output = output 246 | try: 247 | super().__init__(*args, **kwargs) 248 | except TypeError: 249 | # python 2.7 needs super(Baseclass, self) 250 | super(InterruptGetOutput, self).__init__(*args, **kwargs) 251 | 252 | @property 253 | def output(self): 254 | return self._output 255 | 256 | 257 | ### BEGIN DIRECT IMPORT FROM ofunctions.threading 258 | def call_with_future(fn, future, args, kwargs): 259 | """ 260 | Threading a function with return info using Future 261 | from https://stackoverflow.com/a/19846691/2635443 262 | 263 | Example: 264 | 265 | @threaded 266 | def somefunc(arg): 267 | return 'arg was %s' % arg 268 | 269 | 270 | thread = somefunc('foo') 271 | while thread.done() is False: 272 | time.sleep(1) 273 | 274 | print(thread.result()) 275 | """ 276 | try: 277 | result = fn(*args, **kwargs) 278 | future.set_result(result) 279 | except Exception as exc: 280 | future.set_exception(exc) 281 | 282 | 283 | # pylint: disable=E0102 (function-redefined) 284 | def threaded(fn): 285 | """ 286 | @threaded wrapper in order to thread any function 287 | 288 | @wraps decorator sole purpose is for function.__name__ to be the real function 289 | instead of 'wrapper' 290 | 291 | """ 292 | 293 | @wraps(fn) 294 | def wrapper(*args, **kwargs): 295 | if kwargs.pop("__no_threads", False): 296 | return fn(*args, **kwargs) 297 | future = Future() 298 | thread = threading.Thread( 299 | target=call_with_future, args=(fn, future, args, kwargs) 300 | ) 301 | thread.daemon = True 302 | thread.start() 303 | return future 304 | 305 | return wrapper 306 | 307 | 308 | ### END DIRECT IMPORT FROM ofunctions.threading 309 | 310 | 311 | logger = getLogger(__intname__) 312 | PIPE = subprocess.PIPE 313 | 314 | 315 | def _validate_process_priority( 316 | priority, # type: Union[int, str] 317 | ): 318 | # type: (...) -> int 319 | """ 320 | Check if priority int is valid 321 | """ 322 | 323 | def _raise_prio_error(priority, reason): 324 | raise ValueError( 325 | "Priority not valid ({}): {}. Please use one of {}".format( 326 | reason, priority, ", ".join(list(PRIORITIES["process"].keys())) 327 | ) 328 | ) 329 | 330 | if isinstance(priority, int): 331 | if os_name == "nt": 332 | _raise_prio_error(priority, "windows does not accept ints as priority") 333 | if -20 <= priority <= 20: 334 | _raise_prio_error(priority, "priority out of range") 335 | elif isinstance(priority, str): 336 | try: 337 | priority = PRIORITIES["process"][priority.lower()] 338 | except KeyError: 339 | _raise_prio_error(priority, "priority does not exist") 340 | return priority 341 | 342 | 343 | def _set_priority( 344 | pid, # type: int 345 | priority, # type: Union[int, str] 346 | priority_type, # type: str 347 | ): 348 | """ 349 | Set process and / or io prioritie 350 | """ 351 | priority = priority.lower() 352 | 353 | if priority_type == "process": 354 | _priority = _validate_process_priority(priority) 355 | psutil.Process(pid).nice(_priority) 356 | elif priority_type == "io": 357 | valid_io_priorities = list(PRIORITIES["io"].keys()) 358 | if priority not in valid_io_priorities: 359 | raise ValueError( 360 | "Bogus {} priority given: {}. Please use one of {}".format( 361 | priority_type, priority, ", ".join(valid_io_priorities) 362 | ) 363 | ) 364 | psutil.Process(pid).ionice(PRIORITIES[priority_type][priority]) 365 | else: 366 | raise ValueError("Bogus priority type given.") 367 | 368 | 369 | def set_priority( 370 | pid, # type: int 371 | priority, # type: Union[int, str] 372 | ): 373 | """ 374 | Shorthand for _set_priority 375 | """ 376 | _set_priority(pid, priority, "process") 377 | 378 | 379 | def set_io_priority( 380 | pid, # type: int 381 | priority, # type: str 382 | ): 383 | """ 384 | Shorthand for _set_priority 385 | """ 386 | _set_priority(pid, priority, "io") 387 | 388 | 389 | def to_encoding( 390 | process_output, # type: Union[str, bytes] 391 | encoding, # type: Optional[str] 392 | errors, # type: str 393 | ): 394 | # type: (...) -> str 395 | """ 396 | Convert bytes output to string and handles conversion errors 397 | Variation of ofunctions.string_handling.safe_string_convert 398 | """ 399 | 400 | if not encoding: 401 | return process_output 402 | 403 | # Compatibility for earlier Python versions where Popen has no 'encoding' nor 'errors' arguments 404 | if isinstance(process_output, bytes): 405 | try: 406 | process_output = process_output.decode(encoding, errors=errors) 407 | except TypeError: 408 | try: 409 | # handle TypeError: don't know how to handle UnicodeDecodeError in error callback 410 | process_output = process_output.decode(encoding, errors="ignore") 411 | except (ValueError, TypeError): 412 | # What happens when str cannot be concatenated 413 | logger.error("Output cannot be captured {}".format(process_output)) 414 | elif process_output is None: 415 | # We deal with strings. Alter output string to avoid NoneType errors 416 | process_output = "" 417 | return process_output 418 | 419 | 420 | def kill_childs_mod( 421 | pid=None, # type: int 422 | itself=False, # type: bool 423 | soft_kill=False, # type: bool 424 | ): 425 | # type: (...) -> bool 426 | """ 427 | Inline version of ofunctions.kill_childs that has no hard dependency on psutil 428 | 429 | Kills all children of pid (current pid can be obtained with os.getpid()) 430 | If no pid given current pid is taken 431 | Good idea when using multiprocessing, is to call with atexit.register(ofunctions.kill_childs, os.getpid(),) 432 | 433 | Beware: MS Windows does not maintain a process tree, so child dependencies are computed on the fly 434 | Knowing this, orphaned processes (where parent process died) cannot be found and killed this way 435 | 436 | Prefer using process.send_signal() in favor of process.kill() to avoid race conditions when PID was reused too fast 437 | 438 | :param pid: Which pid tree we'll kill 439 | :param itself: Should parent be killed too ? 440 | """ 441 | sig = None 442 | 443 | ### BEGIN COMMAND_RUNNER MOD 444 | if "psutil" not in sys.modules: 445 | logger.error( 446 | "No psutil module present. Can only kill direct pids, not child subtree." 447 | ) 448 | if "signal" not in sys.modules: 449 | logger.error( 450 | "No signal module present. Using direct psutil kill API which might have race conditions when PID is reused too fast." 451 | ) 452 | else: 453 | """ 454 | Warning: There are only a couple of signals supported on Windows platform 455 | 456 | Extract from signal.valid_signals(): 457 | 458 | Windows / Python 3.9-64 459 | {, , , , , , } 460 | 461 | Linux / Python 3.8-64 462 | {, , , , , , , , , , , , , , , 16, , , , , , , , , , , , , , , , , 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, } 463 | 464 | A ValueError will be raised in any other case. Note that not all systems define the same set of signal names; 465 | an AttributeError will be raised if a signal name is not defined as SIG* module level constant. 466 | """ 467 | try: 468 | if not soft_kill and hasattr(signal, "SIGKILL"): 469 | # Don't bother to make pylint go crazy on Windows 470 | # pylint: disable=E1101 471 | sig = signal.SIGKILL 472 | else: 473 | sig = signal.SIGTERM 474 | except NameError: 475 | sig = None 476 | ### END COMMAND_RUNNER MOD 477 | 478 | def _process_killer( 479 | process, # type: Union[subprocess.Popen, psutil.Process] 480 | sig, # type: signal.valid_signals 481 | soft_kill, # type: bool 482 | ): 483 | # (...) -> None 484 | """ 485 | Simple abstract process killer that works with signals in order to avoid reused PID race conditions 486 | and can prefers using terminate than kill 487 | """ 488 | if sig: 489 | try: 490 | process.send_signal(sig) 491 | # psutil.NoSuchProcess might not be available, let's be broad 492 | # pylint: disable=W0703 493 | except Exception: 494 | pass 495 | else: 496 | if soft_kill: 497 | process.terminate() 498 | else: 499 | process.kill() 500 | 501 | try: 502 | current_process = psutil.Process(pid) 503 | # psutil.NoSuchProcess might not be available, let's be broad 504 | # pylint: disable=W0703 505 | except Exception: 506 | if itself: 507 | ### BEGIN COMMAND_RUNNER MOD 508 | try: 509 | os.kill( 510 | pid, 15 511 | ) # 15 being signal.SIGTERM or SIGKILL depending on the platform 512 | except OSError as exc: 513 | if os_name == "nt": 514 | # We'll do an ugly hack since os.kill() has some pretty big caveats on Windows 515 | # especially for Python 2.7 where we can get Access Denied 516 | os.system("taskkill /F /pid {}".format(pid)) 517 | else: 518 | logger.error( 519 | "Could not properly kill process with pid {}: {}".format( 520 | pid, 521 | to_encoding(exc.__str__(), "utf-8", "backslashreplace"), 522 | ) 523 | ) 524 | raise 525 | ### END COMMAND_RUNNER MOD 526 | return False 527 | else: 528 | for child in current_process.children(recursive=True): 529 | _process_killer(child, sig, soft_kill) 530 | 531 | if itself: 532 | _process_killer(current_process, sig, soft_kill) 533 | return True 534 | 535 | 536 | def command_runner( 537 | command, # type: Union[str, List[str]] 538 | valid_exit_codes=False, # type: Union[List[int], bool] 539 | timeout=3600, # type: Optional[int] 540 | shell=False, # type: bool 541 | encoding=None, # type: Optional[Union[str, bool]] 542 | stdin=None, # type: Optional[Union[int, str, Callable, queue.Queue]] 543 | stdout=None, # type: Optional[Union[int, str, Callable, queue.Queue]] 544 | stderr=None, # type: Optional[Union[int, str, Callable, queue.Queue]] 545 | no_close_queues=False, # type: Optional[bool] 546 | windows_no_window=False, # type: bool 547 | live_output=False, # type: bool 548 | method="monitor", # type: str 549 | check_interval=0.05, # type: float 550 | stop_on=None, # type: Callable 551 | on_exit=None, # type: Callable 552 | process_callback=None, # type: Callable 553 | split_streams=False, # type: bool 554 | silent=False, # type: bool 555 | priority=None, # type: Union[int, str] 556 | io_priority=None, # type: str 557 | heartbeat=0, # type: int 558 | **kwargs # type: Any 559 | ): 560 | # type: (...) -> Union[Tuple[int, Optional[Union[bytes, str]]], Tuple[int, Optional[Union[bytes, str]], Optional[Union[bytes, str]]]] 561 | """ 562 | Unix & Windows compatible subprocess wrapper that handles output encoding and timeouts 563 | Newer Python check_output already handles encoding and timeouts, but this one is retro-compatible 564 | It is still recommended to set cp437 for windows and utf-8 for unix 565 | 566 | Also allows a list of various valid exit codes (ie no error when exit code = arbitrary int) 567 | 568 | command should be a list of strings, eg ['ping', '-c 2', '127.0.0.1'] 569 | command can also be a single string, ex 'ping -c 2 127.0.0.1' if shell=True or if os is Windows 570 | 571 | Accepts all of subprocess.popen arguments 572 | 573 | Whenever we can, we need to avoid shell=True in order to preserve better security 574 | Avoiding shell=True involves passing absolute paths to executables since we don't have shell PATH environment 575 | 576 | When no stdout option is given, we'll get output into the returned (exit_code, output) tuple 577 | When stdout = filename or stderr = filename, we'll write output to the given file 578 | 579 | live_output will poll the process for output and show it on screen (output may be non reliable, don't use it if 580 | your program depends on the commands' stdout output) 581 | 582 | windows_no_window will disable visible window (MS Windows platform only) 583 | 584 | stop_on is an optional function that will stop execution if function returns True 585 | 586 | priority and io_priority can be set to 'low', 'normal' or 'high' 587 | priority may also be an int from -20 to 20 on Unix 588 | 589 | heartbeat will log a line every heartbeat seconds informing that we're still alive 590 | 591 | Returns a tuple (exit_code, output) 592 | """ 593 | 594 | # Choose default encoding when none set 595 | # cp437 encoding assures we catch most special characters from cmd.exe 596 | # Unless encoding=False in which case nothing gets encoded except Exceptions and logger strings for Python 2 597 | error_encoding = "cp437" if os_name == "nt" else "utf-8" 598 | if encoding is None: 599 | encoding = error_encoding 600 | 601 | # Fix when unix command was given as single string 602 | # This is more secure than setting shell=True 603 | if os_name == "posix": 604 | if not shell and isinstance(command, str): 605 | command = shlex.split(command) 606 | elif shell and isinstance(command, list): 607 | command = " ".join(command) 608 | 609 | # Set default values for kwargs 610 | errors = kwargs.pop( 611 | "errors", "backslashreplace" 612 | ) # Don't let encoding issues make you mad 613 | universal_newlines = kwargs.pop("universal_newlines", False) 614 | creationflags = kwargs.pop("creationflags", 0) 615 | # subprocess.CREATE_NO_WINDOW was added in Python 3.7 for Windows OS only 616 | if ( 617 | windows_no_window 618 | and sys.version_info[0] >= 3 619 | and sys.version_info[1] >= 7 620 | and os_name == "nt" 621 | ): 622 | # Disable the following pylint error since the code also runs on nt platform, but 623 | # triggers an error on Unix 624 | # pylint: disable=E1101 625 | creationflags = creationflags | subprocess.CREATE_NO_WINDOW 626 | close_fds = kwargs.pop("close_fds", "posix" in sys.builtin_module_names) 627 | 628 | # Default buffer size. line buffer (1) is deprecated in Python 3.7+ 629 | bufsize = kwargs.pop("bufsize", 16384) 630 | 631 | # Decide whether we write to output variable only (stdout=None), to output variable and stdout (stdout=PIPE) 632 | # or to output variable and to file (stdout='path/to/file') 633 | if stdout is None: 634 | _stdout = PIPE 635 | stdout_destination = "pipe" 636 | elif callable(stdout): 637 | _stdout = PIPE 638 | stdout_destination = "callback" 639 | elif isinstance(stdout, queue.Queue): 640 | _stdout = PIPE 641 | stdout_destination = "queue" 642 | elif isinstance(stdout, str): 643 | # We will send anything to file 644 | _stdout = open(stdout, "wb") 645 | stdout_destination = "file" 646 | elif stdout is False: 647 | # Python 2.7 does not have subprocess.DEVNULL, hence we need to use a file descriptor 648 | try: 649 | _stdout = subprocess.DEVNULL 650 | except AttributeError: 651 | _stdout = PIPE 652 | stdout_destination = None 653 | else: 654 | # We will send anything to given stdout pipe 655 | _stdout = stdout 656 | stdout_destination = "pipe" 657 | 658 | # The only situation where we don't add stderr to stdout is if a specific target file was given 659 | if callable(stderr): 660 | _stderr = PIPE 661 | stderr_destination = "callback" 662 | elif isinstance(stderr, queue.Queue): 663 | _stderr = PIPE 664 | stderr_destination = "queue" 665 | elif isinstance(stderr, str): 666 | _stderr = open(stderr, "wb") 667 | stderr_destination = "file" 668 | elif stderr is False: 669 | try: 670 | _stderr = subprocess.DEVNULL 671 | except AttributeError: 672 | _stderr = PIPE 673 | stderr_destination = None 674 | elif stderr is not None: 675 | _stderr = stderr 676 | stderr_destination = "pipe" 677 | # Automagically add a pipe so we are sure not to redirect to stdout 678 | elif split_streams: 679 | _stderr = PIPE 680 | stderr_destination = "pipe" 681 | else: 682 | _stderr = subprocess.STDOUT 683 | stderr_destination = "stdout" 684 | 685 | def _read_pipe( 686 | stream, # type: io.StringIO 687 | output_queue, # type: queue.Queue 688 | ): 689 | # type: (...) -> None 690 | """ 691 | will read from subprocess.PIPE 692 | Must be threaded since readline() might be blocking on Windows GUI apps 693 | 694 | Partly based on https://stackoverflow.com/a/4896288/2635443 695 | """ 696 | 697 | # WARNING: Depending on the stream type (binary or text), the sentinel character 698 | # needs to be of the same type, or the iterator won't have an end 699 | 700 | # We also need to check that stream has readline, in case we're writing to files instead of PIPE 701 | 702 | # Another magnificent python 2.7 fix 703 | # So we need to convert sentinel_char which would be unicode because of unicode_litterals 704 | # to str which is the output format from stream.readline() 705 | 706 | if hasattr(stream, "readline"): 707 | sentinel_char = str("") if hasattr(stream, "encoding") else b"" 708 | for line in iter(stream.readline, sentinel_char): 709 | output_queue.put(line) 710 | output_queue.put(None) 711 | stream.close() 712 | 713 | def _get_error_output(output_stdout, output_stderr): 714 | """ 715 | Try to concatenate output for exceptions if possible 716 | """ 717 | try: 718 | return output_stdout + output_stderr 719 | except TypeError: 720 | if output_stdout: 721 | return output_stdout 722 | if output_stderr: 723 | return output_stderr 724 | return None 725 | 726 | def _heartbeat_thread( 727 | process, # type: Union[subprocess.Popen[str], subprocess.Popen] 728 | heartbeat, # type: int 729 | ): 730 | begin_time = datetime.now() 731 | while True: 732 | elapsed_time = int((datetime.now() - begin_time).total_seconds()) 733 | if elapsed_time > heartbeat and elapsed_time % heartbeat == 0: 734 | logger.info("Still running command after %s seconds" % elapsed_time) 735 | if process.poll() is not None: 736 | break 737 | sleep(1) 738 | 739 | def heartbeat_thread( 740 | process, # type: Union[subprocess.Popen[str], subprocess.Popen] 741 | heartbeat, # type: int 742 | ): 743 | """ 744 | Just a shorthand to run the heartbeat thread 745 | """ 746 | if heartbeat: 747 | heartbeat_thread = threading.Thread( 748 | target=_heartbeat_thread, 749 | args=( 750 | process, 751 | heartbeat, 752 | ), 753 | ) 754 | heartbeat_thread.daemon = True 755 | heartbeat_thread.start() 756 | 757 | def _poll_process( 758 | process, # type: Union[subprocess.Popen[str], subprocess.Popen] 759 | timeout, # type: int 760 | encoding, # type: str 761 | errors, # type: str 762 | ): 763 | # type: (...) -> Union[Tuple[int, Optional[str]], Tuple[int, Optional[str], Optional[str]]] 764 | """ 765 | Process stdout/stderr output polling is only used in live output mode 766 | since it takes more resources than using communicate() 767 | 768 | Reads from process output pipe until: 769 | - Timeout is reached, in which case we'll terminate the process 770 | - Process ends by itself 771 | 772 | Returns an encoded string of the pipe output 773 | """ 774 | 775 | def __check_timeout( 776 | begin_time, # type: datetime.timestamp 777 | timeout, # type: int 778 | ): 779 | # type: (...) -> None 780 | """ 781 | Simple subfunction to check whether timeout is reached 782 | Since we check this a lot, we put it into a function 783 | """ 784 | if timeout and (datetime.now() - begin_time).total_seconds() > timeout: 785 | kill_childs_mod(process.pid, itself=True, soft_kill=False) 786 | raise TimeoutExpired( 787 | process, timeout, _get_error_output(output_stdout, output_stderr) 788 | ) 789 | if stop_on and stop_on(): 790 | kill_childs_mod(process.pid, itself=True, soft_kill=False) 791 | raise StopOnInterrupt(_get_error_output(output_stdout, output_stderr)) 792 | 793 | begin_time = datetime.now() 794 | 795 | heartbeat_thread(process, heartbeat) 796 | 797 | if encoding is False: 798 | output_stdout = output_stderr = b"" 799 | else: 800 | output_stdout = output_stderr = "" 801 | 802 | try: 803 | if stdout_destination is not None: 804 | stdout_read_queue = True 805 | stdout_queue = queue.Queue() 806 | stdout_read_thread = threading.Thread( 807 | target=_read_pipe, args=(process.stdout, stdout_queue) 808 | ) 809 | stdout_read_thread.daemon = True # thread dies with the program 810 | stdout_read_thread.start() 811 | else: 812 | stdout_read_queue = False 813 | 814 | # Don't bother to read stderr if we redirect to stdout 815 | if stderr_destination not in ["stdout", None]: 816 | stderr_read_queue = True 817 | stderr_queue = queue.Queue() 818 | stderr_read_thread = threading.Thread( 819 | target=_read_pipe, args=(process.stderr, stderr_queue) 820 | ) 821 | stderr_read_thread.daemon = True # thread dies with the program 822 | stderr_read_thread.start() 823 | else: 824 | stderr_read_queue = False 825 | 826 | while stdout_read_queue or stderr_read_queue: 827 | if stdout_read_queue: 828 | try: 829 | line = stdout_queue.get(timeout=check_interval) 830 | except queue.Empty: 831 | pass 832 | else: 833 | if line is None: 834 | stdout_read_queue = False 835 | else: 836 | line = to_encoding(line, encoding, errors) 837 | if stdout_destination == "callback": 838 | stdout(line) 839 | if stdout_destination == "queue": 840 | stdout.put(line) 841 | if live_output: 842 | sys.stdout.write(line) 843 | output_stdout += line 844 | 845 | if stderr_read_queue: 846 | try: 847 | line = stderr_queue.get(timeout=check_interval) 848 | except queue.Empty: 849 | pass 850 | else: 851 | if line is None: 852 | stderr_read_queue = False 853 | else: 854 | line = to_encoding(line, encoding, errors) 855 | if stderr_destination == "callback": 856 | stderr(line) 857 | if stderr_destination == "queue": 858 | stderr.put(line) 859 | if live_output: 860 | sys.stderr.write(line) 861 | if split_streams: 862 | output_stderr += line 863 | else: 864 | output_stdout += line 865 | 866 | __check_timeout(begin_time, timeout) 867 | 868 | # Make sure we wait for the process to terminate, even after 869 | # output_queue has finished sending data, so we catch the exit code 870 | while process.poll() is None: 871 | __check_timeout(begin_time, timeout) 872 | # Additional timeout check to make sure we don't return an exit code from processes 873 | # that were killed because of timeout 874 | __check_timeout(begin_time, timeout) 875 | exit_code = process.poll() 876 | if split_streams: 877 | return exit_code, output_stdout, output_stderr 878 | return exit_code, output_stdout 879 | 880 | except KeyboardInterrupt: 881 | raise KbdInterruptGetOutput(_get_error_output(output_stdout, output_stderr)) 882 | 883 | def _timeout_check_thread( 884 | process, # type: Union[subprocess.Popen[str], subprocess.Popen] 885 | timeout, # type: int 886 | must_stop, # type dict 887 | ): 888 | # type: (...) -> None 889 | 890 | """ 891 | Since elder python versions don't have timeout, we need to manually check the timeout for a process 892 | when working in process monitor mode 893 | """ 894 | 895 | begin_time = datetime.now() 896 | while True: 897 | if timeout and (datetime.now() - begin_time).total_seconds() > timeout: 898 | kill_childs_mod(process.pid, itself=True, soft_kill=False) 899 | must_stop["value"] = "T" # T stands for TIMEOUT REACHED 900 | break 901 | if stop_on and stop_on(): 902 | kill_childs_mod(process.pid, itself=True, soft_kill=False) 903 | must_stop["value"] = "S" # S stands for STOP_ON RETURNED TRUE 904 | break 905 | if process.poll() is not None: 906 | break 907 | # We definitely need some sleep time here or else we will overload CPU 908 | sleep(check_interval) 909 | 910 | def _monitor_process( 911 | process, # type: Union[subprocess.Popen[str], subprocess.Popen] 912 | timeout, # type: int 913 | encoding, # type: str 914 | errors, # type: str 915 | ): 916 | # type: (...) -> Union[Tuple[int, Optional[str]], Tuple[int, Optional[str], Optional[str]]] 917 | """ 918 | Create a thread in order to enforce timeout or a stop_on condition 919 | Get stdout output and return it 920 | """ 921 | 922 | # Shared mutable objects have proven to have race conditions with PyPy 3.7 (mutable object 923 | # is changed in thread, but outer monitor function has still old mutable object state) 924 | # Strangely, this happened only sometimes on github actions/ubuntu 20.04.3 & pypy 3.7 925 | # Just make sure the thread is done before using mutable object 926 | must_stop = {"value": False} 927 | 928 | thread = threading.Thread( 929 | target=_timeout_check_thread, 930 | args=(process, timeout, must_stop), 931 | ) 932 | thread.daemon = True # was setDaemon(True) which has been deprecated 933 | thread.start() 934 | 935 | heartbeat_thread(process, heartbeat) 936 | 937 | if encoding is False: 938 | output_stdout = output_stderr = b"" 939 | output_stdout_end = output_stderr_end = b"" 940 | else: 941 | output_stdout = output_stderr = "" 942 | output_stdout_end = output_stderr_end = "" 943 | 944 | try: 945 | # Don't use process.wait() since it may deadlock on old Python versions 946 | # Also it won't allow communicate() to get incomplete output on timeouts 947 | while process.poll() is None: 948 | if must_stop["value"]: 949 | break 950 | # We still need to use process.communicate() in this loop so we don't get stuck 951 | # with poll() is not None even after process is finished, when using shell=True 952 | # Behavior validated on python 3.7 953 | try: 954 | output_stdout, output_stderr = process.communicate() 955 | # ValueError is raised on closed IO file 956 | except (TimeoutExpired, ValueError): 957 | pass 958 | exit_code = process.poll() 959 | 960 | try: 961 | output_stdout_end, output_stderr_end = process.communicate() 962 | except (TimeoutExpired, ValueError): 963 | pass 964 | 965 | # Fix python 2.7 first process.communicate() call will have output whereas other python versions 966 | # will give output in second process.communicate() call 967 | if output_stdout_end and len(output_stdout_end) > 0: 968 | output_stdout = output_stdout_end 969 | if output_stderr_end and len(output_stderr_end) > 0: 970 | output_stderr = output_stderr_end 971 | 972 | if split_streams: 973 | if stdout_destination is not None: 974 | output_stdout = to_encoding(output_stdout, encoding, errors) 975 | if stderr_destination is not None: 976 | output_stderr = to_encoding(output_stderr, encoding, errors) 977 | else: 978 | if stdout_destination is not None: 979 | output_stdout = to_encoding(output_stdout, encoding, errors) 980 | 981 | # On PyPy 3.7 only, we can have a race condition where we try to read the queue before 982 | # the thread could write to it, failing to register a timeout. 983 | # This workaround prevents reading the mutable object while the thread is still alive 984 | while thread.is_alive(): 985 | sleep(check_interval) 986 | 987 | if must_stop["value"] == "T": 988 | raise TimeoutExpired( 989 | process, timeout, _get_error_output(output_stdout, output_stderr) 990 | ) 991 | if must_stop["value"] == "S": 992 | raise StopOnInterrupt(_get_error_output(output_stdout, output_stderr)) 993 | if split_streams: 994 | return exit_code, output_stdout, output_stderr 995 | return exit_code, output_stdout 996 | except KeyboardInterrupt: 997 | raise KbdInterruptGetOutput(_get_error_output(output_stdout, output_stderr)) 998 | 999 | # After all the stuff above, here's finally the function main entry point 1000 | output_stdout = output_stderr = None 1001 | 1002 | try: 1003 | # Don't allow monitor method when stdout or stderr is callback/queue redirection (makes no sense) 1004 | if method == "monitor" and ( 1005 | stdout_destination 1006 | in [ 1007 | "callback", 1008 | "queue", 1009 | ] 1010 | or stderr_destination in ["callback", "queue"] 1011 | ): 1012 | raise ValueError( 1013 | 'Cannot use callback or queue destination in monitor mode. Please use method="poller" argument.' 1014 | ) 1015 | 1016 | # Finally, we won't use encoding & errors arguments for Popen 1017 | # since it would defeat the idea of binary pipe reading in live mode 1018 | 1019 | # Python >= 3.3 has SubProcessError(TimeoutExpired) class 1020 | # Python >= 3.6 has encoding & error arguments 1021 | # Python >= 3.7 has creationflags arguments for process priority under windows 1022 | # universal_newlines=True makes netstat command fail under windows 1023 | # timeout does not work under Python 2.7 with subprocess32 < 3.5 1024 | # decoder may be cp437 or unicode_escape for dos commands or utf-8 for powershell 1025 | 1026 | if priority: 1027 | process_prio = _validate_process_priority(priority) 1028 | # Don't bother to make pylint go crazy on Windows missing os.nice() 1029 | # pylint: disable=E1101 1030 | if os_name == "nt" and sys.version_info >= (3, 7): 1031 | creationflags |= process_prio 1032 | 1033 | # Actually we don't want preexec_fn since it's not thread safe, neither supported 1034 | # in subinterpreters. We'll use set_priority() once the process is spawned 1035 | # else: 1036 | # kwargs["preexec_fn"] = lambda: os.nice(process_prio) 1037 | 1038 | # Disabling pylint error for the same reason as above 1039 | # pylint: disable=E1123 1040 | if sys.version_info >= (3, 6): 1041 | process = subprocess.Popen( 1042 | command, 1043 | stdin=stdin, 1044 | stdout=_stdout, 1045 | stderr=_stderr, 1046 | shell=shell, 1047 | universal_newlines=universal_newlines, 1048 | encoding=encoding if encoding is not False else None, 1049 | errors=errors if encoding is not False else None, 1050 | creationflags=creationflags, 1051 | bufsize=bufsize, # 1 = line buffered 1052 | close_fds=close_fds, 1053 | **kwargs 1054 | ) 1055 | else: 1056 | process = subprocess.Popen( 1057 | command, 1058 | stdin=stdin, 1059 | stdout=_stdout, 1060 | stderr=_stderr, 1061 | shell=shell, 1062 | universal_newlines=universal_newlines, 1063 | creationflags=creationflags, 1064 | bufsize=bufsize, 1065 | close_fds=close_fds, 1066 | **kwargs 1067 | ) 1068 | 1069 | # Set process priority if not set earlier by creationflags 1070 | if priority and ( 1071 | os_name != "nt" or (sys.version_info < (3, 7) and os_name == "nt") 1072 | ): 1073 | try: 1074 | try: 1075 | set_priority(process.pid, priority) 1076 | except psutil.AccessDenied as exc: 1077 | logger.warning( 1078 | "Cannot set process priority {}: {}. Access denied.".format( 1079 | priority, exc 1080 | ) 1081 | ) 1082 | logger.debug("Trace:", exc_info=True) 1083 | except Exception as exc: 1084 | logger.warning( 1085 | "Cannot set process priority {}: {}".format(priority, exc) 1086 | ) 1087 | logger.debug("Trace:", exc_info=True) 1088 | except NameError: 1089 | logger.warning( 1090 | "Cannot set process priority. No psutil module installed." 1091 | ) 1092 | logger.debug("Trace:", exc_info=True) 1093 | # Set io priority if given 1094 | if io_priority: 1095 | try: 1096 | try: 1097 | set_io_priority(process.pid, io_priority) 1098 | except psutil.AccessDenied as exc: 1099 | logger.warning( 1100 | "Cannot set io priority {}: {} Access denied.".format( 1101 | io_priority, exc 1102 | ) 1103 | ) 1104 | logger.debug("Trace:", exc_info=True) 1105 | except Exception as exc: 1106 | logger.warning( 1107 | "Cannot set io priority {}: {}".format(io_priority, exc) 1108 | ) 1109 | logger.debug("Trace:", exc_info=True) 1110 | raise 1111 | except NameError: 1112 | logger.warning("Cannot set io priority. No psutil module installed.") 1113 | 1114 | try: 1115 | # let's return process information if callback was given 1116 | if callable(process_callback): 1117 | process_callback(process) 1118 | if method == "poller" or live_output and _stdout is not False: 1119 | if split_streams: 1120 | exit_code, output_stdout, output_stderr = _poll_process( 1121 | process, timeout, encoding, errors 1122 | ) 1123 | else: 1124 | exit_code, output_stdout = _poll_process( 1125 | process, timeout, encoding, errors 1126 | ) 1127 | elif method == "monitor": 1128 | if split_streams: 1129 | exit_code, output_stdout, output_stderr = _monitor_process( 1130 | process, timeout, encoding, errors 1131 | ) 1132 | else: 1133 | exit_code, output_stdout = _monitor_process( 1134 | process, timeout, encoding, errors 1135 | ) 1136 | else: 1137 | raise ValueError("Unknown method {} provided.".format(method)) 1138 | except KbdInterruptGetOutput as exc: 1139 | exit_code = -252 1140 | output_stdout = "KeyboardInterrupted. Partial output\n{}".format(exc.output) 1141 | try: 1142 | kill_childs_mod(process.pid, itself=True, soft_kill=False) 1143 | except AttributeError: 1144 | pass 1145 | if stdout_destination == "file" and output_stdout: 1146 | _stdout.write(output_stdout.encode(encoding, errors=errors)) 1147 | if stderr_destination == "file" and output_stderr: 1148 | _stderr.write(output_stderr.encode(encoding, errors=errors)) 1149 | elif stdout_destination == "file" and output_stderr: 1150 | _stdout.write(output_stderr.encode(encoding, errors=errors)) 1151 | 1152 | logger.debug( 1153 | 'Command "{}" returned with exit code "{}"'.format(command, exit_code) 1154 | ) 1155 | except subprocess.CalledProcessError as exc: 1156 | exit_code = exc.returncode 1157 | try: 1158 | output_stdout = exc.output 1159 | except AttributeError: 1160 | output_stdout = "command_runner: Could not obtain output from command." 1161 | 1162 | logger_fn = logger.error 1163 | valid_exit_codes_msg = "" 1164 | if valid_exit_codes: 1165 | if valid_exit_codes is True or exit_code in valid_exit_codes: 1166 | logger_fn = logger.info 1167 | valid_exit_codes_msg = " allowed" 1168 | 1169 | if not silent: 1170 | logger_fn( 1171 | 'Command "{}" failed with{} exit code "{}"'.format( 1172 | command, 1173 | valid_exit_codes_msg, 1174 | exc.returncode, 1175 | ) 1176 | ) 1177 | except FileNotFoundError as exc: 1178 | message = 'Command "{}" failed, file not found: {}'.format( 1179 | command, to_encoding(exc.__str__(), error_encoding, errors) 1180 | ) 1181 | if not silent: 1182 | logger.error(message) 1183 | if stdout_destination == "file": 1184 | _stdout.write(message.encode(error_encoding, errors=errors)) 1185 | exit_code, output_stdout = (-253, message) 1186 | # On python 2.7, OSError is also raised when file is not found (no FileNotFoundError) 1187 | # pylint: disable=W0705 (duplicate-except) 1188 | except (OSError, IOError) as exc: 1189 | message = 'Command "{}" failed because of OS: {}'.format( 1190 | command, to_encoding(exc.__str__(), error_encoding, errors) 1191 | ) 1192 | if not silent: 1193 | logger.error(message) 1194 | if stdout_destination == "file": 1195 | _stdout.write(message.encode(error_encoding, errors=errors)) 1196 | exit_code, output_stdout = (-253, message) 1197 | except TimeoutExpired as exc: 1198 | message = 'Timeout {} seconds expired for command "{}" execution. Original output was: {}'.format( 1199 | timeout, command, to_encoding(exc.output, error_encoding, errors)[-1000:] 1200 | ) 1201 | if not silent: 1202 | logger.error(message) 1203 | if stdout_destination == "file": 1204 | _stdout.write(message.encode(error_encoding, errors=errors)) 1205 | exit_code, output_stdout = (-254, message) 1206 | except StopOnInterrupt as exc: 1207 | message = "Command {} was stopped because stop_on function returned True. Original output was: {}".format( 1208 | command, to_encoding(exc.output, error_encoding, errors)[-1000:] 1209 | ) 1210 | if not silent: 1211 | logger.info(message) 1212 | if stdout_destination == "file": 1213 | _stdout.write(message.encode(error_encoding, errors=errors)) 1214 | exit_code, output_stdout = (-251, message) 1215 | except ValueError as exc: 1216 | message = to_encoding(exc.__str__(), error_encoding, errors) 1217 | if not silent: 1218 | logger.error(message, exc_info=True) 1219 | if stdout_destination == "file": 1220 | _stdout.write(message) 1221 | exit_code, output_stdout = (-250, message) 1222 | # We need to be able to catch a broad exception 1223 | # pylint: disable=W0703 1224 | except Exception as exc: 1225 | if not silent: 1226 | logger.error( 1227 | 'Command "{}" failed for unknown reasons: {}'.format( 1228 | command, to_encoding(exc.__str__(), error_encoding, errors) 1229 | ), 1230 | exc_info=True, 1231 | ) 1232 | exit_code, output_stdout = ( 1233 | -255, 1234 | to_encoding(exc.__str__(), error_encoding, errors), 1235 | ) 1236 | finally: 1237 | if stdout_destination == "file": 1238 | _stdout.close() 1239 | if stderr_destination == "file": 1240 | _stderr.close() 1241 | 1242 | # Make sure we send a simple queue end before leaving to make sure any queue read process will stop regardless 1243 | # of command_runner state (useful when launching with queue and method poller which isn't supposed to write queues) 1244 | if not no_close_queues: 1245 | if stdout_destination == "queue": 1246 | stdout.put(None) 1247 | if stderr_destination == "queue": 1248 | stderr.put(None) 1249 | 1250 | # With polling, we return None if nothing has been send to the queues 1251 | # With monitor, process.communicate() will result in '' even if nothing has been sent 1252 | # Let's fix this here 1253 | # Python 2.7 will return False to u'' == '' (UnicodeWarning: Unicode equal comparison failed) 1254 | # so we have to make the following statement 1255 | if stdout_destination is None or ( 1256 | output_stdout is not None and len(output_stdout) == 0 1257 | ): 1258 | output_stdout = None 1259 | if stderr_destination is None or ( 1260 | output_stderr is not None and len(output_stderr) == 0 1261 | ): 1262 | output_stderr = None 1263 | 1264 | stdout_output = to_encoding(output_stdout, error_encoding, errors) 1265 | if stdout_output: 1266 | logger.debug("STDOUT: " + stdout_output) 1267 | if stderr_destination not in ["stdout", None]: 1268 | stderr_output = to_encoding(output_stderr, error_encoding, errors) 1269 | if stderr_output and not silent: 1270 | if exit_code == 0 or ( 1271 | valid_exit_codes 1272 | and valid_exit_codes is True 1273 | or exit_code in valid_exit_codes 1274 | ): 1275 | logger.debug("STDERR: " + stderr_output) 1276 | else: 1277 | logger.error("STDERR: " + stderr_output) 1278 | 1279 | if on_exit: 1280 | logger.debug("Running on_exit callable.") 1281 | on_exit() 1282 | 1283 | if split_streams: 1284 | return exit_code, output_stdout, output_stderr 1285 | return exit_code, _get_error_output(output_stdout, output_stderr) 1286 | 1287 | 1288 | if sys.version_info[0] >= 3: 1289 | 1290 | @threaded 1291 | def command_runner_threaded(*args, **kwargs): 1292 | """ 1293 | Threaded version of command_runner_threaded which returns concurrent.Future result 1294 | Not available for Python 2.7 1295 | """ 1296 | return command_runner(*args, **kwargs) 1297 | 1298 | 1299 | def deferred_command(command, defer_time=300): 1300 | # type: (str, int) -> None 1301 | """ 1302 | This is basically an ugly hack to launch commands which are detached from parent process 1303 | Especially useful to launch an auto update/deletion of a running executable after a given amount of 1304 | seconds after it finished 1305 | """ 1306 | # Use ping as a standard timer in shell since it's present on virtually *any* system 1307 | if os_name == "nt": 1308 | deferrer = "ping 127.0.0.1 -n {} > NUL & ".format(defer_time) 1309 | else: 1310 | deferrer = "sleep {} && ".format(defer_time) 1311 | 1312 | # We'll create a independent shell process that will not be attached to any stdio interface 1313 | # Our command shall be a single string since shell=True 1314 | subprocess.Popen( 1315 | deferrer + command, 1316 | shell=True, 1317 | stdin=None, 1318 | stdout=None, 1319 | stderr=None, 1320 | close_fds=True, 1321 | ) 1322 | -------------------------------------------------------------------------------- /command_runner/elevate.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This file is part of command_runner module 5 | 6 | """ 7 | elevate is a Windows/ unix compatible function elevator for Python 3+ 8 | 9 | usage: 10 | import sys 11 | from elevate import elevate 12 | 13 | def main(argv): 14 | print('Hello world, with arguments %s' % argv) 15 | 16 | # Hey, check my exit code ;) 17 | sys.exit(123) 18 | 19 | if __name__ == '__main__': 20 | elevate(main, sys.argv) 21 | 22 | Versioning semantics: 23 | Major version: backward compatibility breaking changes 24 | Minor version: New functionality 25 | Patch version: Backwards compatible bug fixes 26 | 27 | """ 28 | 29 | __intname__ = "command_runner.elevate" 30 | __author__ = "Orsiris de Jong" 31 | __copyright__ = "Copyright (C) 2017-2025 Orsiris de Jong for NetInvent" 32 | __licence__ = "BSD 3 Clause" 33 | __version__ = "0.3.3" 34 | __build__ = "2024091501" 35 | 36 | from typing import Tuple 37 | from logging import getLogger 38 | import os 39 | import sys 40 | from command_runner import command_runner 41 | 42 | OS_NAME = os.name 43 | if OS_NAME == "nt": 44 | try: 45 | import win32event # monitor process 46 | import win32process # monitor process 47 | from win32com.shell.shell import ShellExecuteEx 48 | from win32com.shell.shell import IsUserAnAdmin 49 | from win32com.shell import shellcon 50 | except ImportError: 51 | raise ImportError( 52 | "Cannot import ctypes for checking admin privileges on Windows platform." 53 | ) 54 | 55 | logger = getLogger(__name__) 56 | 57 | 58 | def is_admin(): 59 | # type: () -> bool 60 | """ 61 | Checks whether current program has administrative privileges in OS 62 | Works with Windows XP SP2+ and most Unixes 63 | 64 | :return: Boolean, True if admin privileges present 65 | """ 66 | 67 | # Works with XP SP2 + 68 | if OS_NAME == "nt": 69 | try: 70 | return IsUserAnAdmin() 71 | except Exception: 72 | raise EnvironmentError("Cannot check admin privileges") 73 | elif OS_NAME == "posix": 74 | # Check for root on Posix 75 | # os.getuid only exists on postix OSes 76 | # pylint: disable=E1101 (no-member) 77 | return os.getuid() == 0 78 | else: 79 | raise EnvironmentError( 80 | "OS does not seem to be supported for admin check. OS: {}".format(OS_NAME) 81 | ) 82 | 83 | 84 | def get_absolute_path(executable): 85 | # type: (str) -> str 86 | """ 87 | Search for full executable path in preferred shell paths 88 | This allows avoiding usage of shell=True with subprocess 89 | """ 90 | 91 | executable_path = None 92 | exit_code, output = command_runner(["type", "-p", "sudo"]) 93 | if exit_code == 0: 94 | # Remove ending '\n'' character 95 | output = output.strip() 96 | if os.path.isfile(output): 97 | return output 98 | 99 | if OS_NAME == "nt": 100 | split_char = ";" 101 | else: 102 | split_char = ":" 103 | for path in os.environ.get("PATH", "").split(split_char): 104 | if os.path.isfile(os.path.join(path, executable)): 105 | executable_path = os.path.join(path, executable) 106 | return executable_path 107 | 108 | 109 | def _windows_runner(runner, arguments): 110 | # type: (str, str) -> int 111 | # Old method using ctypes which does not wait for executable to exit nor does get exit code 112 | # See https://docs.microsoft.com/en-us/windows/desktop/api/shellapi/nf-shellapi-shellexecutew 113 | # int 0 means SH_HIDE window, 1 is SW_SHOWNORMAL 114 | # needs the following imports 115 | # import ctypes 116 | # ctypes.windll.shell32.ShellExecuteW(None, 'runas', runner, arguments, None, 0) 117 | 118 | # Method with exit code that waits for executable to exit, needs the following imports 119 | # import win32event # monitor process 120 | # import win32process # monitor process 121 | # from win32com.shell.shell import ShellExecuteEx 122 | # from win32com.shell import shellcon 123 | # pylint: disable=C0103 (invalid-name) 124 | # pylint: disable=E0606 (possibly-used-before-assignment) 125 | childProcess = ShellExecuteEx( 126 | nShow=0, 127 | fMask=shellcon.SEE_MASK_NOCLOSEPROCESS, 128 | lpVerb="runas", 129 | lpFile=runner, 130 | lpParameters=arguments, 131 | ) 132 | 133 | # pylint: disable=C0103 (invalid-name) 134 | procHandle = childProcess["hProcess"] 135 | # pylint: disable=I1101 (c-extension-no-member) 136 | # pylint: disable=E0606 (possibly-used-before-assignment) 137 | win32event.WaitForSingleObject(procHandle, win32event.INFINITE) 138 | # pylint: disable=I1101 (c-extension-no-member) 139 | # pylint: disable=E0606 (possibly-used-before-assignment) 140 | exit_code = win32process.GetExitCodeProcess(procHandle) 141 | return exit_code 142 | 143 | 144 | def _check_environment(): 145 | # type: () -> Tuple[str, str] 146 | # Regardless of the runner (CPython, Nuitka or frozen CPython), sys.argv[0] is the relative path to script, 147 | # sys.argv[1] are the arguments 148 | # The only exception being CPython on Windows where sys.argv[0] contains absolute path to script 149 | # Regardless of OS, sys.executable will contain full path to python binary for CPython and Nuitka, 150 | # and full path to frozen executable on frozen CPython 151 | 152 | # Recapitulative table create with 153 | # (CentOS 7x64 / Python 3.4 / Nuitka 0.6.1 / PyInstaller 3.4) and 154 | # (Windows 10 x64 / Python 3.7x32 / Nuitka 0.6.2.10 / PyInstaller 3.4) 155 | # -------------------------------------------------------------------------------------------------------------- 156 | # | OS | Variable | CPython | Nuitka | PyInstaller | 157 | # |------------------------------------------------------------------------------------------------------------| 158 | # | Lin | argv | ['./test.py', '-h'] | ['./test', '-h'] | ['./test.py', -h'] | 159 | # | Lin | sys.executable | /usr/bin/python3.4 | /usr/bin/python3.4 | /absolute/path/to/test | 160 | # | Win | argv | ['C:\\Python\\test.py', '-h'] | ['test', '-h'] | ['test', '-h'] | 161 | # | Win | sys.executable | C:\Python\python.exe | C:\Python\Python.exe | C:\absolute\path\to\test.exe | 162 | # -------------------------------------------------------------------------------------------------------------- 163 | 164 | # Nuitka > 0.8 just declares __compiled__ variables 165 | # Nuitka 0.6.2 and newer define builtin __nuitka_binary_dir 166 | # Nuitka does not set the frozen attribute on sys 167 | # Nuitka < 0.6.2 can be detected in sloppy ways, ie if not sys.argv[0].endswith('.py') or len(sys.path) < 3 168 | # Let's assume this will only be compiled with newer nuitka, and remove sloppy detections 169 | is_nuitka_compiled = False 170 | try: 171 | # Actual if statement not needed, but keeps code inspectors more happy 172 | if "__compiled__" in globals(): 173 | is_nuitka_compiled = True 174 | except NameError: 175 | pass 176 | 177 | if is_nuitka_compiled: 178 | # On nuitka, sys.executable is the python binary, even if it does not exist in standalone, 179 | # so we need to fill runner with sys.argv[0] absolute path 180 | runner = os.path.abspath(sys.argv[0]) 181 | arguments = sys.argv[1:] 182 | # current_dir = os.path.dirname(runner) 183 | logger.debug('Running elevator as Nuitka with runner "{}"'.format(runner)) 184 | # If a freezer is used (PyInstaller, cx_freeze, py2exe) 185 | elif getattr(sys, "frozen", False): 186 | runner = os.path.abspath(sys.executable) 187 | arguments = sys.argv[1:] 188 | # current_dir = os.path.dirname(runner) 189 | logger.debug('Running elevator as Frozen with runner "{}"'.format(runner)) 190 | # If standard interpreter CPython is used 191 | else: 192 | runner = os.path.abspath(sys.executable) 193 | arguments = [os.path.abspath(sys.argv[0])] + sys.argv[1:] 194 | # current_dir = os.path.abspath(sys.argv[0]) 195 | logger.debug('Running elevator as CPython with runner "{}"'.format(runner)) 196 | logger.debug('Arguments are "{}"'.format(arguments)) 197 | return runner, arguments 198 | 199 | 200 | def elevate(callable_function, *args, **kwargs): 201 | """ 202 | UAC elevation / sudo code working for CPython, Nuitka >= 0.6.2, PyInstaller, PyExe, CxFreeze 203 | """ 204 | if is_admin(): 205 | # Don't bother if we already got mighty admin privileges 206 | callable_function(*args, **kwargs) 207 | else: 208 | runner, arguments = _check_environment() 209 | # Windows runner 210 | if OS_NAME == "nt": 211 | # Re-run the script with admin rights 212 | # Join arguments and double quote each argument in order to prevent space separation 213 | arguments = " ".join('"' + arg + '"' for arg in arguments) 214 | try: 215 | exit_code = _windows_runner(runner, arguments) 216 | logger.debug('Child exited with code "{}"'.format(exit_code)) 217 | sys.exit(exit_code) 218 | 219 | except Exception as exc: 220 | logger.info(exc) 221 | logger.debug("Trace:", exc_info=True) 222 | sys.exit(255) 223 | # Linux runner and hopefully Unixes 224 | else: 225 | # Re-run the script but with sudo 226 | sudo_path = get_absolute_path("sudo") 227 | if sudo_path is None: 228 | logger.error( 229 | "Cannot find sudo executable. Trying to run without privileges elevation." 230 | ) 231 | callable_function(*args, **kwargs) 232 | else: 233 | command = ["sudo", runner] + arguments 234 | # Optionally might also pass a stdout PIPE to command_runner so we get live output 235 | exit_code, output = command_runner(command, shell=False, timeout=None) 236 | 237 | logger.info("Child output: {}".format(output)) 238 | sys.exit(exit_code) 239 | -------------------------------------------------------------------------------- /command_runner/requirements.txt: -------------------------------------------------------------------------------- 1 | psutil>=5.6.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This file is part of command_runner package 5 | 6 | 7 | __intname__ = "command_runner.setup" 8 | __author__ = "Orsiris de Jong" 9 | __copyright__ = "Copyright (C) 2021-2025 Orsiris de Jong for NetInvent" 10 | __licence__ = "BSD 3 Clause" 11 | __build__ = "2022092801" 12 | 13 | 14 | PACKAGE_NAME = "command_runner" 15 | DESCRIPTION = "Platform agnostic command and shell execution tool, also allows UAC/sudo privilege elevation" 16 | 17 | import sys 18 | import os 19 | 20 | import pkg_resources 21 | import setuptools 22 | 23 | 24 | def _read_file(filename): 25 | here = os.path.abspath(os.path.dirname(__file__)) 26 | if sys.version_info[0] < 3: 27 | # With python 2.7, open has no encoding parameter, resulting in TypeError 28 | # Fix with io.open (slow but works) 29 | from io import open as io_open 30 | 31 | try: 32 | with io_open( 33 | os.path.join(here, filename), "r", encoding="utf-8" 34 | ) as file_handle: 35 | return file_handle.read() 36 | except IOError: 37 | # Ugly fix for missing requirements.txt file when installing via pip under Python 2 38 | return "psutil\n" 39 | else: 40 | with open(os.path.join(here, filename), "r", encoding="utf-8") as file_handle: 41 | return file_handle.read() 42 | 43 | 44 | def get_metadata(package_file): 45 | """ 46 | Read metadata from package file 47 | """ 48 | 49 | _metadata = {} 50 | 51 | for line in _read_file(package_file).splitlines(): 52 | if line.startswith("__version__") or line.startswith("__description__"): 53 | delim = "=" 54 | _metadata[line.split(delim)[0].strip().strip("__")] = ( 55 | line.split(delim)[1].strip().strip("'\"") 56 | ) 57 | return _metadata 58 | 59 | 60 | def parse_requirements(filename): 61 | """ 62 | There is a parse_requirements function in pip but it keeps changing import path 63 | Let's build a simple one 64 | """ 65 | try: 66 | requirements_txt = _read_file(filename) 67 | install_requires = [ 68 | str(requirement) 69 | for requirement in pkg_resources.parse_requirements(requirements_txt) 70 | ] 71 | return install_requires 72 | except OSError: 73 | print( 74 | 'WARNING: No requirements.txt file found as "{}". Please check path or create an empty one'.format( 75 | filename 76 | ) 77 | ) 78 | 79 | 80 | package_path = os.path.abspath(PACKAGE_NAME) 81 | package_file = os.path.join(package_path, "__init__.py") 82 | metadata = get_metadata(package_file) 83 | requirements = parse_requirements(os.path.join(package_path, "requirements.txt")) 84 | long_description = _read_file("README.md") 85 | 86 | setuptools.setup( 87 | name=PACKAGE_NAME, 88 | # We may use find_packages in order to not specify each package manually 89 | # packages = ['command_runner'], 90 | packages=setuptools.find_packages(), 91 | version=metadata["version"], 92 | install_requires=requirements, 93 | classifiers=[ 94 | # command_runner is mature 95 | "Development Status :: 5 - Production/Stable", 96 | "Intended Audience :: Developers", 97 | "Topic :: Software Development", 98 | "Topic :: System", 99 | "Topic :: System :: Operating System", 100 | "Topic :: System :: Shells", 101 | "Programming Language :: Python", 102 | "Programming Language :: Python :: 3", 103 | "Programming Language :: Python :: Implementation :: CPython", 104 | "Programming Language :: Python :: Implementation :: PyPy", 105 | "Operating System :: POSIX :: Linux", 106 | "Operating System :: POSIX :: BSD :: FreeBSD", 107 | "Operating System :: POSIX :: BSD :: NetBSD", 108 | "Operating System :: POSIX :: BSD :: OpenBSD", 109 | "Operating System :: Microsoft", 110 | "Operating System :: Microsoft :: Windows", 111 | "License :: OSI Approved :: BSD License", 112 | ], 113 | description=DESCRIPTION, 114 | license="BSD", 115 | author="NetInvent - Orsiris de Jong", 116 | author_email="contact@netinvent.fr", 117 | url="https://github.com/netinvent/command_runner", 118 | keywords=[ 119 | "shell", 120 | "execution", 121 | "subprocess", 122 | "check_output", 123 | "wrapper", 124 | "uac", 125 | "sudo", 126 | "elevate", 127 | "privilege", 128 | ], 129 | long_description=long_description, 130 | long_description_content_type="text/markdown", 131 | python_requires=">=2.7", 132 | ) 133 | -------------------------------------------------------------------------------- /tests/test_command_runner.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This file is part of command_runner module 5 | 6 | """ 7 | command_runner is a quick tool to launch commands from Python, get exit code 8 | and output, and handle most errors that may happen 9 | 10 | Versioning semantics: 11 | Major version: backward compatibility breaking changes 12 | Minor version: New functionality 13 | Patch version: Backwards compatible bug fixes 14 | 15 | """ 16 | 17 | __intname__ = "command_runner_tests" 18 | __author__ = "Orsiris de Jong" 19 | __copyright__ = "Copyright (C) 2015-2025 Orsiris de Jong" 20 | __licence__ = "BSD 3 Clause" 21 | __build__ = "2025041801" 22 | 23 | 24 | import sys 25 | import os 26 | import platform 27 | import re 28 | import threading 29 | import logging 30 | import collections 31 | 32 | try: 33 | from command_runner import * 34 | except ImportError: # would be ModuleNotFoundError in Python 3+ 35 | # In case we run tests without actually having installed command_runner 36 | sys.path.insert(0, os.path.abspath(os.path.join(__file__, os.pardir, os.pardir))) 37 | from command_runner import * 38 | 39 | # Python 2.7 compat where datetime.now() does not have .timestamp() method 40 | if sys.version_info[0] < 3 or sys.version_info[1] < 4: 41 | # python version < 3.3 42 | import time 43 | 44 | def timestamp(date): 45 | return time.mktime(date.timetuple()) 46 | 47 | else: 48 | 49 | def timestamp(date): 50 | return date.timestamp() 51 | 52 | 53 | # We need a logging unit here 54 | logger = logging.getLogger() 55 | logger.setLevel(logging.WARNING) 56 | handler = logging.StreamHandler(sys.stdout) 57 | handler.setLevel(logging.WARNING) 58 | formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 59 | handler.setFormatter(formatter) 60 | logger.addHandler(handler) 61 | 62 | streams = ["stdout", "stderr"] 63 | methods = ["monitor", "poller"] 64 | 65 | TEST_FILENAME = "README.md" 66 | if os.name == "nt": 67 | ENCODING = "cp437" 68 | PING_CMD = "ping 127.0.0.1 -n 4" 69 | PING_CMD_10S = "ping 127.0.0.1 -n 10" 70 | PING_CMD_REDIR = PING_CMD + " 1>&2" 71 | # Make sure we run the failure command first so end result is okay 72 | PING_CMD_AND_FAILURE = "ping 0.0.0.0 -n 2 1>&2 & ping 127.0.0.1 -n 2" 73 | PING_FAILURE = "ping 0.0.0.0 -n 2 1>&2" 74 | 75 | PRINT_FILE_CMD = "type {}".format(TEST_FILENAME) 76 | else: 77 | ENCODING = "utf-8" 78 | PING_CMD = ["ping", "-c", "4", "127.0.0.1"] 79 | PING_CMD_10S = ["ping", "-c", "10", "127.0.0.1"] 80 | PING_CMD_REDIR = "ping -c 4 127.0.0.1 1>&2" 81 | PING_CMD_AND_FAILURE = "ping -c 2 0.0.0.0 1>&2; ping -c 2 127.0.0.1" 82 | PRINT_FILE_CMD = "cat {}".format(TEST_FILENAME) 83 | PING_FAILURE = "ping -c 2 0.0.0.0 1>&2" 84 | 85 | 86 | ELAPSED_TIME = timestamp(datetime.now()) 87 | PROCESS_ID = None 88 | STREAM_OUTPUT = "" 89 | PROC = None 90 | ON_EXIT_CALLED = False 91 | 92 | 93 | def reset_elapsed_time(): 94 | global ELAPSED_TIME 95 | ELAPSED_TIME = timestamp(datetime.now()) 96 | 97 | 98 | def get_elapsed_time(): 99 | return timestamp(datetime.now()) - ELAPSED_TIME 100 | 101 | 102 | def running_on_github_actions(): 103 | """ 104 | This is set in github actions workflow with 105 | env: 106 | RUNNING_ON_GITHUB_ACTIONS: true 107 | """ 108 | return os.environ.get("RUNNING_ON_GITHUB_ACTIONS") == "true" # bash 'true' 109 | 110 | 111 | def is_pypy(): 112 | """ 113 | Checks interpreter 114 | """ 115 | return True if platform.python_implementation().lower() == "pypy" else False 116 | 117 | 118 | def is_macos(): 119 | """ 120 | Checks if under Mac OS 121 | """ 122 | return platform.system().lower() == "darwin" 123 | 124 | 125 | def test_standard_ping_with_encoding(): 126 | """ 127 | Test command_runner with a standard ping and encoding parameter 128 | """ 129 | for method in methods: 130 | print("method={}".format(method)) 131 | exit_code, output = command_runner(PING_CMD, encoding=ENCODING, method=method) 132 | print(output) 133 | assert ( 134 | exit_code == 0 135 | ), "Exit code should be 0 for ping command with method {}".format(method) 136 | 137 | 138 | def test_standard_ping_with_default_encoding(): 139 | """ 140 | Without encoding, iter(stream.readline, '') will hang since the expected sentinel char would be b'': 141 | This could only happen on python <3.6 since command_runner decides to use an encoding anyway 142 | """ 143 | for method in methods: 144 | exit_code, output = command_runner(PING_CMD, encoding=None, method=method) 145 | print(output) 146 | assert ( 147 | exit_code == 0 148 | ), "Exit code should be 0 for ping command with method {}".format(method) 149 | 150 | 151 | def test_standard_ping_with_encoding_disabled(): 152 | """ 153 | Without encoding disabled, we should have binary output 154 | """ 155 | for method in methods: 156 | exit_code, output = command_runner(PING_CMD, encoding=False, method=method) 157 | print(output) 158 | assert ( 159 | exit_code == 0 160 | ), "Exit code should be 0 for ping command with method {}".format(method) 161 | assert isinstance(output, bytes), "Output should be binary." 162 | 163 | 164 | def test_timeout(): 165 | """ 166 | Test command_runner with a timeout 167 | """ 168 | for method in methods: 169 | begin_time = datetime.now() 170 | exit_code, output = command_runner(PING_CMD, timeout=1, method=method) 171 | print(output) 172 | end_time = datetime.now() 173 | assert ( 174 | end_time - begin_time 175 | ).total_seconds() < 2, "It took more than 2 seconds for a timeout=1 command to finish with method {}".format( 176 | method 177 | ) 178 | assert ( 179 | exit_code == -254 180 | ), "Exit code should be -254 on timeout with method {}".format(method) 181 | assert "Timeout" in output, "Output should have timeout with method {}".format( 182 | method 183 | ) 184 | 185 | 186 | def test_timeout_with_subtree_killing(): 187 | """ 188 | Launch a subtree of long commands and see if timeout actually kills them in time 189 | """ 190 | if os.name != "nt": 191 | cmd = 'echo "test" && sleep 5 && echo "done"' 192 | else: 193 | cmd = "echo test && {} && echo done".format(PING_CMD) 194 | 195 | for method in methods: 196 | begin_time = datetime.now() 197 | exit_code, output = command_runner(cmd, shell=True, timeout=1, method=method) 198 | print(output) 199 | end_time = datetime.now() 200 | elapsed_time = (end_time - begin_time).total_seconds() 201 | assert ( 202 | elapsed_time < 4 203 | ), "It took more than 2 seconds for a timeout=1 command to finish with method {}".format( 204 | method 205 | ) 206 | assert ( 207 | exit_code == -254 208 | ), "Exit code should be -254 on timeout with method {}".format(method) 209 | assert "Timeout" in output, "Output should have timeout with method {}".format( 210 | method 211 | ) 212 | 213 | 214 | def test_no_timeout(): 215 | """ 216 | Test with setting timeout=None 217 | """ 218 | for method in methods: 219 | exit_code, output = command_runner(PING_CMD, timeout=None, method=method) 220 | print(output) 221 | assert ( 222 | exit_code == 0 223 | ), "Without timeout, command should have run with method {}".format(method) 224 | 225 | 226 | def test_live_output(): 227 | """ 228 | Test command_runner with live output to stdout 229 | """ 230 | for method in methods: 231 | exit_code, _ = command_runner( 232 | PING_CMD, stdout=PIPE, encoding=ENCODING, method=method 233 | ) 234 | assert ( 235 | exit_code == 0 236 | ), "Exit code should be 0 for ping command with method {}".format(method) 237 | 238 | 239 | def test_not_found(): 240 | """ 241 | Test command_runner with an unexisting command 242 | """ 243 | for method in methods: 244 | print("The following command should fail with method {}".format(method)) 245 | exit_code, output = command_runner("unknown_command_nowhere_to_be_found_1234") 246 | assert ( 247 | exit_code == -253 248 | ), "Unknown command should trigger a -253 exit code with method {}".format( 249 | method 250 | ) 251 | assert "failed" in output, "Error code -253 should be Command x failed, reason" 252 | 253 | 254 | def test_file_output(): 255 | """ 256 | Test command_runner with file output instead of stdout 257 | """ 258 | for method in methods: 259 | stdout_filename = "temp.test" 260 | stderr_filename = "temp.test.err" 261 | print("The following command should timeout") 262 | exit_code, output = command_runner( 263 | PING_CMD, 264 | timeout=1, 265 | stdout=stdout_filename, 266 | stderr=stderr_filename, 267 | method=method, 268 | ) 269 | assert os.path.isfile( 270 | stdout_filename 271 | ), "Log file does not exist with method {}".format(method) 272 | 273 | # We don't have encoding argument in Python 2, yet we need it for PyPy 274 | if sys.version_info[0] < 3: 275 | with open(stdout_filename, "r") as file_handle: 276 | output = file_handle.read() 277 | else: 278 | with open(stdout_filename, "r", encoding=ENCODING) as file_handle: 279 | output = file_handle.read() 280 | 281 | assert os.path.isfile( 282 | stderr_filename 283 | ), "stderr log file does not exist with method {}".format(method) 284 | assert ( 285 | exit_code == -254 286 | ), "Exit code should be -254 for timeouts with method {}".format(method) 287 | assert "Timeout" in output, "Output should have timeout with method {}".format( 288 | method 289 | ) 290 | 291 | # arbitrary time to make sure file handle was closed 292 | sleep(3) 293 | os.remove(stdout_filename) 294 | os.remove(stderr_filename) 295 | 296 | 297 | def test_valid_exit_codes(): 298 | """ 299 | Test command_runner with a failed ping but that should not trigger an error 300 | 301 | # WIP We could improve tests here by capturing logs 302 | """ 303 | valid_exit_codes = [0, 1, 2] 304 | if is_macos(): 305 | valid_exit_codes.append(68) # ping non-existent exits with such on Mac 306 | for method in methods: 307 | 308 | exit_code, _ = command_runner( 309 | "ping nonexistent_host", 310 | shell=True, 311 | valid_exit_codes=valid_exit_codes, 312 | method=method, 313 | ) 314 | assert ( 315 | exit_code in valid_exit_codes 316 | ), "Exit code not in valid list with method {}".format(method) 317 | 318 | exit_code, _ = command_runner( 319 | "ping nonexistent_host", shell=True, valid_exit_codes=True, method=method 320 | ) 321 | assert exit_code != 0, "Exit code should not be equal to 0" 322 | 323 | exit_code, _ = command_runner( 324 | "ping nonexistent_host", shell=True, valid_exit_codes=False, method=method 325 | ) 326 | assert exit_code != 0, "Exit code should not be equal to 0" 327 | 328 | exit_code, _ = command_runner( 329 | "ping nonexistent_host", shell=True, valid_exit_codes=None, method=method 330 | ) 331 | assert exit_code != 0, "Exit code should not be equal to 0" 332 | 333 | 334 | def test_unix_only_split_command(): 335 | """ 336 | This test is specifically written when command_runner receives a str command instead of a list on unix 337 | """ 338 | if os.name == "posix": 339 | for method in methods: 340 | exit_code, _ = command_runner(" ".join(PING_CMD), method=method) 341 | assert ( 342 | exit_code == 0 343 | ), "Non split command should not trigger an error with method {}".format( 344 | method 345 | ) 346 | 347 | 348 | def test_create_no_window(): 349 | """ 350 | Only used on windows, when we don't want to create a cmd visible windows 351 | """ 352 | for method in methods: 353 | exit_code, _ = command_runner(PING_CMD, windows_no_window=True, method=method) 354 | assert exit_code == 0, "Should have worked too with method {}".format(method) 355 | 356 | 357 | def test_read_file(): 358 | """ 359 | Read a couple of times the same file to be sure we don't get garbage from _read_pipe() 360 | This is a random failure detection test 361 | """ 362 | 363 | # We don't have encoding argument in Python 2, yet we need it for PyPy 364 | if sys.version_info[0] < 3: 365 | with open(TEST_FILENAME, "r") as file: 366 | file_content = file.read() 367 | else: 368 | with open(TEST_FILENAME, "r", encoding=ENCODING) as file: 369 | file_content = file.read() 370 | for method in methods: 371 | # pypy is quite slow with poller method on github actions. 372 | # Lets lower rounds 373 | max_rounds = 100 if is_pypy() else 1000 374 | print("\nSetting up test_read_file for {} rounds".format(max_rounds)) 375 | for round in range(0, max_rounds): 376 | print("Comparison round {} with method {}".format(round, method)) 377 | exit_code, output = command_runner( 378 | PRINT_FILE_CMD, shell=True, method=method 379 | ) 380 | if os.name == "nt": 381 | output = output.replace("\r\n", "\n") 382 | 383 | assert ( 384 | exit_code == 0 385 | ), "Did not succeed to read {}, method={}, exit_code: {}, output: {}".format( 386 | TEST_FILENAME, method, exit_code, output 387 | ) 388 | assert ( 389 | file_content == output 390 | ), "Round {} File content and output are not identical, method={}".format( 391 | round, method 392 | ) 393 | 394 | 395 | def test_stop_on_argument(): 396 | expected_output_regex = "Command .* was stopped because stop_on function returned True. Original output was:" 397 | 398 | def stop_on(): 399 | """ 400 | Simple function that returns True two seconds after reset_elapsed_time() has been called 401 | """ 402 | if get_elapsed_time() > 2: 403 | return True 404 | 405 | for method in methods: 406 | reset_elapsed_time() 407 | print("method={}".format(method)) 408 | exit_code, output = command_runner(PING_CMD, stop_on=stop_on, method=method) 409 | 410 | # On github actions only with Python 2.7.18, we sometimes get -251 failed because of OS: [Error 5] Access is denied 411 | # when os.kill(pid) is called in kill_childs_mod 412 | # On my windows platform using the same Python version, it works... 413 | # well nothing I can debug on github actions 414 | if running_on_github_actions() and os.name == "nt" and sys.version_info[0] < 3: 415 | assert exit_code in [ 416 | -253, 417 | -251, 418 | ], "Not as expected, we should get a permission error on github actions windows platform" 419 | else: 420 | assert ( 421 | exit_code == -251 422 | ), "Monitor mode should have been stopped by stop_on with exit_code -251. method={}, exit_code: {}, output: {}".format( 423 | method, exit_code, output 424 | ) 425 | assert ( 426 | re.match(expected_output_regex, output, re.MULTILINE) is not None 427 | ), "stop_on output is bogus. method={}, exit_code: {}, output: {}".format( 428 | method, exit_code, output 429 | ) 430 | 431 | 432 | def test_process_callback(): 433 | def callback(process_id): 434 | global PROCESS_ID 435 | PROCESS_ID = process_id 436 | 437 | for method in methods: 438 | exit_code, output = command_runner( 439 | PING_CMD, method=method, process_callback=callback 440 | ) 441 | assert ( 442 | exit_code == 0 443 | ), "Wrong exit code. method={}, exit_code: {}, output: {}".format( 444 | method, exit_code, output 445 | ) 446 | assert isinstance( 447 | PROCESS_ID, subprocess.Popen 448 | ), 'callback did not work properly. PROCESS_ID="{}"'.format(PROCESS_ID) 449 | 450 | 451 | def test_stream_callback(): 452 | global STREAM_OUTPUT 453 | 454 | def stream_callback(string): 455 | global STREAM_OUTPUT 456 | STREAM_OUTPUT += string 457 | print("CALLBACK: ", string) 458 | 459 | for stream in streams: 460 | stream_args = {stream: stream_callback} 461 | for method in methods: 462 | STREAM_OUTPUT = "" 463 | try: 464 | print("Method={}, stream={}, output=callback".format(method, stream)) 465 | exit_code, output = command_runner( 466 | PING_CMD_REDIR, shell=True, method=method, **stream_args 467 | ) 468 | except ValueError: 469 | if method == "poller": 470 | assert False, "ValueError should not be produced in poller mode." 471 | if method == "poller": 472 | assert ( 473 | exit_code == 0 474 | ), "Wrong exit code. method={}, exit_code: {}, output: {}".format( 475 | method, exit_code, output 476 | ) 477 | 478 | # Since we redirect STDOUT to STDERR 479 | assert ( 480 | STREAM_OUTPUT == output 481 | ), "Callback stream should contain same result as output" 482 | else: 483 | assert ( 484 | exit_code == -250 485 | ), "stream_callback exit_code is bogus. method={}, exit_code: {}, output: {}".format( 486 | method, exit_code, output 487 | ) 488 | 489 | 490 | def test_queue_output(): 491 | """ 492 | Thread command runner and get it's output queue 493 | """ 494 | 495 | if sys.version_info[0] < 3: 496 | print("Queue test uses concurrent futures. Won't run on python 2.7, sorry.") 497 | return 498 | 499 | # pypy is quite slow with poller method on github actions. 500 | # Lets lower rounds 501 | max_rounds = 100 if is_pypy() else 1000 502 | print("\nSetting up test_read_file for {} rounds".format(max_rounds)) 503 | for i in range(0, max_rounds): 504 | for stream in streams: 505 | for method in methods: 506 | if method == "monitor" and i > 1: 507 | # Dont bother to repeat the test for monitor mode more than once 508 | continue 509 | output_queue = queue.Queue() 510 | stream_output = "" 511 | stream_args = {stream: output_queue} 512 | print( 513 | "Round={}, Method={}, stream={}, output=queue".format( 514 | i, method, stream 515 | ) 516 | ) 517 | thread_result = command_runner_threaded( 518 | PRINT_FILE_CMD, shell=True, method=method, **stream_args 519 | ) 520 | 521 | read_queue = True 522 | while read_queue: 523 | try: 524 | line = output_queue.get(timeout=0.1) 525 | except queue.Empty: 526 | pass 527 | else: 528 | if line is None: 529 | break 530 | else: 531 | stream_output += line 532 | 533 | exit_code, output = thread_result.result() 534 | 535 | if method == "poller": 536 | assert ( 537 | exit_code == 0 538 | ), "Wrong exit code. method={}, exit_code: {}, output: {}".format( 539 | method, exit_code, output 540 | ) 541 | # Since we redirect STDOUT to STDERR 542 | if stream == "stdout": 543 | assert ( 544 | stream_output == output 545 | ), "stdout queue output should contain same result as output" 546 | if stream == "stderr": 547 | assert ( 548 | len(stream_output) == 0 549 | ), "stderr queue output should be empty" 550 | else: 551 | assert ( 552 | exit_code == -250 553 | ), "stream_queue exit_code is bogus. method={}, exit_code: {}, output: {}".format( 554 | method, exit_code, output 555 | ) 556 | 557 | 558 | def test_queue_non_threaded_command_runner(): 559 | """ 560 | Test case for Python 2.7 without proper threading return values 561 | """ 562 | 563 | def read_queue(output_queue, stream_output): 564 | """ 565 | Read the queue as thread 566 | Our problem here is that the thread can live forever if we don't check a global value, which is...well ugly 567 | """ 568 | read_queue = True 569 | while read_queue: 570 | try: 571 | line = output_queue.get(timeout=1) 572 | except queue.Empty: 573 | pass 574 | else: 575 | # The queue reading can be stopped once 'None' is received. 576 | if line is None: 577 | read_queue = False 578 | else: 579 | stream_output["value"] += line 580 | # ADD YOUR LIVE CODE HERE 581 | return stream_output 582 | 583 | for i in range(0, 20): 584 | for cmd in [PING_CMD, PRINT_FILE_CMD]: 585 | if cmd == PRINT_FILE_CMD: 586 | shell_args = {"shell": True} 587 | else: 588 | shell_args = {"shell": False} 589 | # Create a new queue that command_runner will fill up 590 | output_queue = queue.Queue() 591 | stream_output = {"value": ""} 592 | # Create a thread of read_queue() in order to read the queue while command_runner executes the command 593 | read_thread = threading.Thread( 594 | target=read_queue, args=(output_queue, stream_output) 595 | ) 596 | read_thread.daemon = True # thread dies with the program 597 | read_thread.start() 598 | 599 | # Launch command_runner 600 | print("Round={}, cmd={}".format(i, cmd)) 601 | exit_code, output = command_runner( 602 | cmd, stdout=output_queue, method="poller", **shell_args 603 | ) 604 | assert ( 605 | exit_code == 0 606 | ), "PING_CMD Exit code is not okay. exit_code={}, output={}".format( 607 | exit_code, output 608 | ) 609 | 610 | # Wait until we are sure that we emptied the queue 611 | while not output_queue.empty(): 612 | sleep(0.1) 613 | 614 | assert stream_output["value"] == output, "Output should be identical" 615 | 616 | 617 | def test_double_queue_threaded_stop(): 618 | """ 619 | Use both stdout and stderr queues and make them stop 620 | """ 621 | 622 | if sys.version_info[0] < 3: 623 | print("Queue test uses concurrent futures. Won't run on python 2.7, sorry.") 624 | return 625 | 626 | stdout_queue = queue.Queue() 627 | stderr_queue = queue.Queue() 628 | thread_result = command_runner_threaded( 629 | PING_CMD_AND_FAILURE, 630 | method="poller", 631 | shell=True, 632 | stdout=stdout_queue, 633 | stderr=stderr_queue, 634 | ) 635 | 636 | print("Begin to read queues") 637 | read_stdout = read_stderr = True 638 | while read_stdout or read_stderr: 639 | try: 640 | stdout_line = stdout_queue.get(timeout=0.1) 641 | except queue.Empty: 642 | pass 643 | else: 644 | if stdout_line is None: 645 | read_stdout = False 646 | print("stdout is finished") 647 | else: 648 | print("STDOUT:", stdout_line) 649 | 650 | try: 651 | stderr_line = stderr_queue.get(timeout=0.1) 652 | except queue.Empty: 653 | pass 654 | else: 655 | if stderr_line is None: 656 | read_stderr = False 657 | print("stderr is finished") 658 | else: 659 | print("STDERR:", stderr_line) 660 | 661 | while True: 662 | done = thread_result.done() 663 | print("Thread is done:", done) 664 | if done: 665 | break 666 | sleep(1) 667 | 668 | exit_code, _ = thread_result.result() 669 | assert exit_code == 0, "We did not succeed in running the thread" 670 | 671 | 672 | def test_deferred_command(): 673 | """ 674 | Using deferred_command in order to run a command after a given timespan 675 | """ 676 | test_filename = "deferred_test_file" 677 | if os.path.isfile(test_filename): 678 | os.remove(test_filename) 679 | deferred_command("echo test > {}".format(test_filename), defer_time=5) 680 | assert os.path.isfile(test_filename) is False, "File should not exist yet" 681 | sleep(6) 682 | assert os.path.isfile(test_filename) is True, "File should exist now" 683 | os.remove(test_filename) 684 | 685 | 686 | def test_powershell_output(): 687 | # Don't bother to test powershell on other platforms than windows 688 | if os.name != "nt": 689 | return 690 | """ 691 | Parts from windows_tools.powershell are used here 692 | """ 693 | 694 | powershell_interpreter = None 695 | # Try to guess powershell path if no valid path given 696 | interpreter_executable = "powershell.exe" 697 | for syspath in ["sysnative", "system32"]: 698 | try: 699 | # Let's try native powershell (64 bit) first or else 700 | # Import-Module may fail when running 32 bit powershell on 64 bit arch 701 | best_guess = os.path.join( 702 | os.environ.get("SYSTEMROOT", "C:"), 703 | syspath, 704 | "WindowsPowerShell", 705 | "v1.0", 706 | interpreter_executable, 707 | ) 708 | if os.path.isfile(best_guess): 709 | powershell_interpreter = best_guess 710 | break 711 | except KeyError: 712 | pass 713 | if powershell_interpreter is None: 714 | try: 715 | ps_paths = os.path.dirname(os.environ["PSModulePath"]).split(";") 716 | for ps_path in ps_paths: 717 | if ps_path.endswith("Modules"): 718 | ps_path = ps_path.strip("Modules") 719 | possible_ps_path = os.path.join(ps_path, interpreter_executable) 720 | if os.path.isfile(possible_ps_path): 721 | powershell_interpreter = possible_ps_path 722 | break 723 | except KeyError: 724 | pass 725 | 726 | if powershell_interpreter is None: 727 | raise OSError("Could not find any valid powershell interpreter") 728 | 729 | # Do not add -NoProfile so we don't end up in a path we're not supposed to 730 | command = powershell_interpreter + " -NonInteractive -NoLogo %s" % PING_CMD 731 | exit_code, output = command_runner(command, encoding="unicode_escape") 732 | print("powershell: ", exit_code, output) 733 | assert exit_code == 0, "Powershell execution failed." 734 | 735 | 736 | def test_null_redir(): 737 | for method in methods: 738 | print("method={}".format(method)) 739 | exit_code, output = command_runner(PING_CMD, stdout=False) 740 | print(exit_code) 741 | print("OUTPUT:", output) 742 | assert output is None, "We should not have any output here" 743 | 744 | exit_code, output = command_runner( 745 | PING_CMD_AND_FAILURE, shell=True, stderr=False 746 | ) 747 | print(exit_code) 748 | print("OUTPUT:", output) 749 | assert "0.0.0.0" not in output, "We should not get error output from here" 750 | 751 | for method in methods: 752 | print("method={}".format(method)) 753 | exit_code, stdout, stderr = command_runner( 754 | PING_CMD, split_streams=True, stdout=False, stderr=False 755 | ) 756 | print(exit_code) 757 | print("STDOUT:", stdout) 758 | print("STDERR:", stderr) 759 | assert stdout is None, "We should not have any output from stdout" 760 | assert stderr is None, "We should not have any output from stderr" 761 | 762 | exit_code, stdout, stderr = command_runner( 763 | PING_CMD_AND_FAILURE, 764 | shell=True, 765 | split_streams=True, 766 | stdout=False, 767 | stderr=False, 768 | ) 769 | print(exit_code) 770 | print("STDOUT:", stdout) 771 | print("STDERR:", stderr) 772 | assert stdout is None, "We should not have any output from stdout" 773 | assert stderr is None, "We should not have any output from stderr" 774 | 775 | 776 | def test_split_streams(): 777 | """ 778 | Test replacing output with stdout and stderr output 779 | """ 780 | for cmd in [PING_CMD, PING_CMD_AND_FAILURE]: 781 | for method in methods: 782 | print("cmd={}, method={}".format(cmd, method)) 783 | 784 | try: 785 | exit_code, _ = command_runner( 786 | cmd, method=method, shell=True, split_streams=True 787 | ) 788 | except ValueError: 789 | # Should generate a valueError 790 | pass 791 | except Exception as exc: 792 | assert ( 793 | False 794 | ), "We should have too many values to unpack here: {}".format(exc) 795 | 796 | exit_code, stdout, stderr = command_runner( 797 | cmd, method=method, shell=True, split_streams=True 798 | ) 799 | print("exit_code:", exit_code) 800 | print("STDOUT:", stdout) 801 | print("STDERR:", stderr) 802 | if cmd == PING_CMD: 803 | assert ( 804 | exit_code == 0 805 | ), "Exit code should be 0 for ping command with method {}".format( 806 | method 807 | ) 808 | assert "127.0.0.1" in stdout 809 | assert stderr is None 810 | if cmd == PING_CMD_AND_FAILURE: 811 | assert ( 812 | exit_code == 0 813 | ), "Exit code should be 0 for ping command with method {}".format( 814 | method 815 | ) 816 | assert "127.0.0.1" in stdout 817 | assert "0.0.0.0" in stderr 818 | 819 | 820 | def test_on_exit(): 821 | def on_exit(): 822 | global ON_EXIT_CALLED 823 | ON_EXIT_CALLED = True 824 | 825 | exit_code, _ = command_runner(PING_CMD, on_exit=on_exit) 826 | assert exit_code == 0, "Exit code is not null" 827 | assert ON_EXIT_CALLED is True, "On exit was never called" 828 | 829 | 830 | def test_no_close_queues(): 831 | """ 832 | Test no_close_queues 833 | """ 834 | 835 | if sys.version_info[0] < 3: 836 | print("Queue test uses concurrent futures. Won't run on python 2.7, sorry.") 837 | return 838 | 839 | stdout_queue = queue.Queue() 840 | stderr_queue = queue.Queue() 841 | thread_result = command_runner_threaded( 842 | PING_CMD_AND_FAILURE, 843 | method="poller", 844 | shell=True, 845 | stdout=stdout_queue, 846 | stderr=stderr_queue, 847 | no_close_queues=True, 848 | ) 849 | 850 | print("Begin to read queues") 851 | read_stdout = read_stderr = True 852 | wait_period = 50 # let's have 100 rounds of 2x timeout 0.1s = 10 seconds, which should be enough for exec to terminate 853 | while read_stdout or read_stderr: 854 | try: 855 | stdout_line = stdout_queue.get(timeout=0.1) 856 | except queue.Empty: 857 | pass 858 | else: 859 | if stdout_line is None: 860 | assert False, "STDOUT queue has been closed with no_close_queues" 861 | else: 862 | print("STDOUT:", stdout_line) 863 | 864 | try: 865 | stderr_line = stderr_queue.get(timeout=0.1) 866 | except queue.Empty: 867 | pass 868 | else: 869 | if stderr_line is None: 870 | assert False, "STDOUT queue has been closed with no_close_queues" 871 | else: 872 | print("STDERR:", stderr_line) 873 | wait_period -= 1 874 | if wait_period < 1: 875 | break 876 | 877 | while True: 878 | done = thread_result.done() 879 | print("Thread is done:", done) 880 | if done: 881 | break 882 | sleep(1) 883 | 884 | exit_code, _ = thread_result.result() 885 | assert exit_code == 0, "We did not succeed in running the thread" 886 | 887 | 888 | def test_low_priority(): 889 | def check_low_priority(process): 890 | niceness = psutil.Process(process.pid).nice() 891 | io_niceness = psutil.Process(process.pid).ionice() 892 | if os.name == "nt": 893 | assert niceness == 16384, "Process low prio niceness not properly set: {}".format( 894 | niceness 895 | ) 896 | assert io_niceness == 1, "Process low prio io niceness not set properly: {}".format( 897 | io_niceness 898 | ) 899 | else: 900 | assert niceness == 15, "Process low prio niceness not properly set: {}".format( 901 | niceness 902 | ) 903 | assert io_niceness == 3, "Process low prio io niceness not set properly: {}".format( 904 | io_niceness 905 | ) 906 | print("Nice !") 907 | 908 | def command_runner_thread(): 909 | return command_runner_threaded( 910 | PING_CMD, 911 | priority="low", 912 | io_priority="low", 913 | process_callback=check_low_priority, 914 | ) 915 | 916 | thread = threading.Thread(target=command_runner_thread, args=()) 917 | thread.daemon = True # thread dies with the program 918 | thread.start() 919 | 920 | 921 | def test_high_priority(): 922 | def check_high_priority(process): 923 | niceness = psutil.Process(process.pid).nice() 924 | io_niceness = psutil.Process(process.pid).ionice() 925 | if os.name == "nt": 926 | assert niceness == 128, "Process high prio niceness not properly set: {}".format( 927 | niceness 928 | ) 929 | # So se actually don't test this here, since high prio cannot be set on Windows unless 930 | # we have NtSetInformationProcess privilege 931 | # assert io_niceness == 3, "Process high prio io niceness not set properly: {}".format( 932 | # io_niceness 933 | # ) 934 | else: 935 | assert niceness == -15, "Process high prio niceness not properly set: {}".format( 936 | niceness 937 | ) 938 | assert io_niceness == 1, "Process high prio io niceness not set properly: {}".format( 939 | io_niceness 940 | ) 941 | print("Nice !") 942 | 943 | def command_runner_thread(): 944 | return command_runner_threaded( 945 | PING_CMD, 946 | priority="high", 947 | # io_priority="high", 948 | process_callback=check_high_priority, 949 | ) 950 | 951 | thread = threading.Thread(target=command_runner_thread, args=()) 952 | thread.daemon = True # thread dies with the program 953 | thread.start() 954 | 955 | 956 | def test_heartbeat(): 957 | # Log capture class, blatantly copied from https://stackoverflow.com/a/37967421/2635443 958 | class TailLogHandler(logging.Handler): 959 | 960 | def __init__(self, log_queue): 961 | logging.Handler.__init__(self) 962 | self.log_queue = log_queue 963 | 964 | def emit(self, record): 965 | self.log_queue.append(self.format(record)) 966 | 967 | 968 | class TailLogger(object): 969 | 970 | def __init__(self, maxlen): 971 | self._log_queue = collections.deque(maxlen=maxlen) 972 | self._log_handler = TailLogHandler(self._log_queue) 973 | 974 | def contents(self): 975 | return "\n".join(self._log_queue) 976 | 977 | @property 978 | def log_handler(self): 979 | return self._log_handler 980 | 981 | tail = TailLogger(10) 982 | 983 | formatter = logging.Formatter( 984 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 985 | ) 986 | 987 | log_handler = tail.log_handler 988 | log_handler.setFormatter(formatter) 989 | logger.addHandler(log_handler) # Add the handler to the logger 990 | logger.setLevel(logging.INFO) 991 | 992 | exit_code, output = command_runner( 993 | PING_CMD_10S, heartbeat=2, shell=False 994 | ) 995 | log_contents = tail.contents() 996 | print("LOGS:\n", log_contents) 997 | print("END LOGS") 998 | print("COMMAND_OUTPUT:\n", output) 999 | assert exit_code == 0, "Exit code should be 0 for ping command with heartbeat" 1000 | # We should have a modulo 2 heeatbeat 1001 | assert ( 1002 | "Still running command after 4 seconds" in log_contents 1003 | ), "Output should have heartbeat" 1004 | 1005 | 1006 | if __name__ == "__main__": 1007 | print("Example code for %s, %s" % (__intname__, __build__)) 1008 | 1009 | test_standard_ping_with_encoding() 1010 | test_standard_ping_with_default_encoding() 1011 | test_standard_ping_with_encoding_disabled() 1012 | test_timeout() 1013 | test_timeout_with_subtree_killing() 1014 | test_no_timeout() 1015 | test_live_output() 1016 | test_not_found() 1017 | test_file_output() 1018 | test_valid_exit_codes() 1019 | test_unix_only_split_command() 1020 | test_create_no_window() 1021 | test_read_file() 1022 | test_stop_on_argument() 1023 | test_process_callback() 1024 | test_stream_callback() 1025 | test_queue_output() 1026 | test_queue_non_threaded_command_runner() 1027 | test_double_queue_threaded_stop() 1028 | test_deferred_command() 1029 | test_powershell_output() 1030 | test_null_redir() 1031 | test_split_streams() 1032 | test_on_exit() 1033 | test_no_close_queues() 1034 | test_low_priority() 1035 | test_high_priority() 1036 | test_heartbeat() 1037 | 1038 | --------------------------------------------------------------------------------