├── .bumpversion.cfg ├── .cookiecutterrc ├── .coveragerc ├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── github-actions.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.rst ├── ci ├── bootstrap.py ├── requirements.txt └── templates │ └── .github │ └── workflows │ └── github-actions.yml ├── docs ├── authors.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst ├── readme.rst ├── reference │ └── index.rst ├── requirements.txt └── spelling_wordlist.txt ├── holdup.spec ├── pyinstaller.Dockerfile ├── pyproject.toml ├── pytest.ini ├── setup.py ├── src └── holdup │ ├── __init__.py │ ├── __main__.py │ ├── checks.py │ ├── cli.py │ └── pg.py ├── tests ├── Dockerfile ├── conftest.py ├── docker-compose.yml ├── test_holdup.py ├── test_pg.py └── test_pg.sh └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 5.1.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version="{current_version}" 8 | replace = version="{new_version}" 9 | 10 | [bumpversion:file (badge):README.rst] 11 | search = /v{current_version}.svg 12 | replace = /v{new_version}.svg 13 | 14 | [bumpversion:file (link):README.rst] 15 | search = /v{current_version}...master 16 | replace = /v{new_version}...master 17 | 18 | [bumpversion:file:docs/conf.py] 19 | search = version = release = "{current_version}" 20 | replace = version = release = "{new_version}" 21 | 22 | [bumpversion:file:src/holdup/__init__.py] 23 | search = __version__ = "{current_version}" 24 | replace = __version__ = "{new_version}" 25 | 26 | [bumpversion:file:.cookiecutterrc] 27 | search = version: {current_version} 28 | replace = version: {new_version} 29 | -------------------------------------------------------------------------------- /.cookiecutterrc: -------------------------------------------------------------------------------- 1 | # Generated by cookiepatcher, a small shim around cookiecutter (pip install cookiepatcher) 2 | 3 | default_context: 4 | c_extension_optional: 'no' 5 | c_extension_support: 'no' 6 | codacy: 'no' 7 | codacy_projectid: '[Get ID from https://app.codacy.com/app/ionelmc/python-holdup/settings]' 8 | codeclimate: 'no' 9 | codecov: 'no' 10 | command_line_interface: argparse 11 | command_line_interface_bin_name: holdup 12 | coveralls: 'yes' 13 | distribution_name: holdup 14 | email: contact@ionelmc.ro 15 | formatter_quote_style: double 16 | full_name: Ionel Cristian Mărieș 17 | function_name: compute 18 | github_actions: 'yes' 19 | github_actions_osx: 'yes' 20 | github_actions_windows: 'no' 21 | license: BSD 2-Clause License 22 | module_name: core 23 | package_name: holdup 24 | pre_commit: 'yes' 25 | project_name: Holdup 26 | project_short_description: A tool to wait for services and execute command. Useful for Docker containers that depend on slow to start services (like almost everything). 27 | pypi_badge: 'yes' 28 | pypi_disable_upload: 'no' 29 | release_date: '2023-02-14' 30 | repo_hosting: github.com 31 | repo_hosting_domain: github.com 32 | repo_main_branch: master 33 | repo_name: python-holdup 34 | repo_username: ionelmc 35 | scrutinizer: 'no' 36 | setup_py_uses_setuptools_scm: 'no' 37 | sphinx_docs: 'yes' 38 | sphinx_docs_hosting: https://python-holdup.readthedocs.io/ 39 | sphinx_doctest: 'no' 40 | sphinx_theme: furo 41 | test_matrix_separate_coverage: 'no' 42 | tests_inside_package: 'no' 43 | version: 5.1.1 44 | version_manager: bump2version 45 | website: https://blog.ionelmc.ro 46 | year_from: '2016' 47 | year_to: '2024' 48 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | src 4 | */site-packages 5 | 6 | [run] 7 | branch = true 8 | source = 9 | holdup 10 | tests 11 | parallel = true 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | omit = *migrations* 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | build 3 | dist 4 | htmlcov 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see https://editorconfig.org/ 2 | root = true 3 | 4 | [*] 5 | # Use Unix-style newlines for most files (except Windows files, see below). 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | insert_final_newline = true 10 | indent_size = 4 11 | charset = utf-8 12 | 13 | [*.{bat,cmd,ps1}] 14 | end_of_line = crlf 15 | 16 | [*.{yml,yaml}] 17 | indent_size = 2 18 | 19 | [*.tsv] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | test: 5 | name: ${{ matrix.name }} 6 | runs-on: ${{ matrix.os }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.11' 14 | toxpython: 'python3.11' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | - name: 'docs' 18 | python: '3.11' 19 | toxpython: 'python3.11' 20 | tox_env: 'docs' 21 | os: 'ubuntu-latest' 22 | - name: 'py38-pg2 (ubuntu)' 23 | python: '3.8' 24 | toxpython: 'python3.8' 25 | python_arch: 'x64' 26 | tox_env: 'py38-pg2' 27 | os: 'ubuntu-latest' 28 | - name: 'py38-pg2 (macos)' 29 | python: '3.8' 30 | toxpython: 'python3.8' 31 | python_arch: 'arm64' 32 | tox_env: 'py38' 33 | os: 'macos-latest' 34 | - name: 'py38-pg3 (ubuntu)' 35 | python: '3.8' 36 | toxpython: 'python3.8' 37 | python_arch: 'x64' 38 | tox_env: 'py38-pg3' 39 | os: 'ubuntu-latest' 40 | - name: 'py38-pg3 (macos)' 41 | python: '3.8' 42 | toxpython: 'python3.8' 43 | python_arch: 'arm64' 44 | tox_env: 'py38' 45 | os: 'macos-latest' 46 | - name: 'py39-pg2 (ubuntu)' 47 | python: '3.9' 48 | toxpython: 'python3.9' 49 | python_arch: 'x64' 50 | tox_env: 'py39-pg2' 51 | os: 'ubuntu-latest' 52 | - name: 'py39-pg2 (macos)' 53 | python: '3.9' 54 | toxpython: 'python3.9' 55 | python_arch: 'arm64' 56 | tox_env: 'py39' 57 | os: 'macos-latest' 58 | - name: 'py39-pg3 (ubuntu)' 59 | python: '3.9' 60 | toxpython: 'python3.9' 61 | python_arch: 'x64' 62 | tox_env: 'py39-pg3' 63 | os: 'ubuntu-latest' 64 | - name: 'py39-pg3 (macos)' 65 | python: '3.9' 66 | toxpython: 'python3.9' 67 | python_arch: 'arm64' 68 | tox_env: 'py39' 69 | os: 'macos-latest' 70 | - name: 'py310-pg2 (ubuntu)' 71 | python: '3.10' 72 | toxpython: 'python3.10' 73 | python_arch: 'x64' 74 | tox_env: 'py310-pg2' 75 | os: 'ubuntu-latest' 76 | - name: 'py310-pg2 (macos)' 77 | python: '3.10' 78 | toxpython: 'python3.10' 79 | python_arch: 'arm64' 80 | tox_env: 'py310' 81 | os: 'macos-latest' 82 | - name: 'py310-pg3 (ubuntu)' 83 | python: '3.10' 84 | toxpython: 'python3.10' 85 | python_arch: 'x64' 86 | tox_env: 'py310-pg3' 87 | os: 'ubuntu-latest' 88 | - name: 'py310-pg3 (macos)' 89 | python: '3.10' 90 | toxpython: 'python3.10' 91 | python_arch: 'arm64' 92 | tox_env: 'py310' 93 | os: 'macos-latest' 94 | - name: 'py311-pg2 (ubuntu)' 95 | python: '3.11' 96 | toxpython: 'python3.11' 97 | python_arch: 'x64' 98 | tox_env: 'py311-pg2' 99 | os: 'ubuntu-latest' 100 | - name: 'py311-pg2 (macos)' 101 | python: '3.11' 102 | toxpython: 'python3.11' 103 | python_arch: 'arm64' 104 | tox_env: 'py311' 105 | os: 'macos-latest' 106 | - name: 'py311-pg3 (ubuntu)' 107 | python: '3.11' 108 | toxpython: 'python3.11' 109 | python_arch: 'x64' 110 | tox_env: 'py311-pg3' 111 | os: 'ubuntu-latest' 112 | - name: 'py311-pg3 (macos)' 113 | python: '3.11' 114 | toxpython: 'python3.11' 115 | python_arch: 'arm64' 116 | tox_env: 'py311' 117 | os: 'macos-latest' 118 | - name: 'py312-pg2 (ubuntu)' 119 | python: '3.12' 120 | toxpython: 'python3.12' 121 | python_arch: 'x64' 122 | tox_env: 'py312-pg2' 123 | os: 'ubuntu-latest' 124 | - name: 'py312-pg2 (macos)' 125 | python: '3.12' 126 | toxpython: 'python3.12' 127 | python_arch: 'arm64' 128 | tox_env: 'py312' 129 | os: 'macos-latest' 130 | - name: 'py312-pg3 (ubuntu)' 131 | python: '3.12' 132 | toxpython: 'python3.12' 133 | python_arch: 'x64' 134 | tox_env: 'py312-pg3' 135 | os: 'ubuntu-latest' 136 | - name: 'py312-pg3 (macos)' 137 | python: '3.12' 138 | toxpython: 'python3.12' 139 | python_arch: 'arm64' 140 | tox_env: 'py312' 141 | os: 'macos-latest' 142 | - name: 'pypy38-pg2 (ubuntu)' 143 | python: 'pypy-3.8' 144 | toxpython: 'pypy3.8' 145 | python_arch: 'x64' 146 | tox_env: 'pypy38-pg2' 147 | os: 'ubuntu-latest' 148 | - name: 'pypy38-pg2 (macos)' 149 | python: 'pypy-3.8' 150 | toxpython: 'pypy3.8' 151 | python_arch: 'arm64' 152 | tox_env: 'pypy38' 153 | os: 'macos-latest' 154 | - name: 'pypy38-pg3 (ubuntu)' 155 | python: 'pypy-3.8' 156 | toxpython: 'pypy3.8' 157 | python_arch: 'x64' 158 | tox_env: 'pypy38-pg3' 159 | os: 'ubuntu-latest' 160 | - name: 'pypy38-pg3 (macos)' 161 | python: 'pypy-3.8' 162 | toxpython: 'pypy3.8' 163 | python_arch: 'arm64' 164 | tox_env: 'pypy38' 165 | os: 'macos-latest' 166 | - name: 'pypy39-pg2 (ubuntu)' 167 | python: 'pypy-3.9' 168 | toxpython: 'pypy3.9' 169 | python_arch: 'x64' 170 | tox_env: 'pypy39-pg2' 171 | os: 'ubuntu-latest' 172 | - name: 'pypy39-pg2 (macos)' 173 | python: 'pypy-3.9' 174 | toxpython: 'pypy3.9' 175 | python_arch: 'arm64' 176 | tox_env: 'pypy39' 177 | os: 'macos-latest' 178 | - name: 'pypy39-pg3 (ubuntu)' 179 | python: 'pypy-3.9' 180 | toxpython: 'pypy3.9' 181 | python_arch: 'x64' 182 | tox_env: 'pypy39-pg3' 183 | os: 'ubuntu-latest' 184 | - name: 'pypy39-pg3 (macos)' 185 | python: 'pypy-3.9' 186 | toxpython: 'pypy3.9' 187 | python_arch: 'arm64' 188 | tox_env: 'pypy39' 189 | os: 'macos-latest' 190 | - name: 'pypy310-pg2 (ubuntu)' 191 | python: 'pypy-3.10' 192 | toxpython: 'pypy3.10' 193 | python_arch: 'x64' 194 | tox_env: 'pypy310-pg2' 195 | os: 'ubuntu-latest' 196 | - name: 'pypy310-pg2 (macos)' 197 | python: 'pypy-3.10' 198 | toxpython: 'pypy3.10' 199 | python_arch: 'arm64' 200 | tox_env: 'pypy310' 201 | os: 'macos-latest' 202 | - name: 'pypy310-pg3 (ubuntu)' 203 | python: 'pypy-3.10' 204 | toxpython: 'pypy3.10' 205 | python_arch: 'x64' 206 | tox_env: 'pypy310-pg3' 207 | os: 'ubuntu-latest' 208 | - name: 'pypy310-pg3 (macos)' 209 | python: 'pypy-3.10' 210 | toxpython: 'pypy3.10' 211 | python_arch: 'arm64' 212 | tox_env: 'pypy310' 213 | os: 'macos-latest' 214 | steps: 215 | - uses: actions/checkout@v4 216 | with: 217 | fetch-depth: 0 218 | - uses: actions/setup-python@v5 219 | with: 220 | python-version: ${{ matrix.python }} 221 | architecture: ${{ matrix.python_arch }} 222 | - name: install dependencies 223 | run: | 224 | python -mpip install --progress-bar=off -r ci/requirements.txt 225 | virtualenv --version 226 | pip --version 227 | tox --version 228 | pip list --format=freeze 229 | - name: test 230 | env: 231 | TOXPYTHON: '${{ matrix.toxpython }}' 232 | run: > 233 | tox -e ${{ matrix.tox_env }} -v 234 | finish: 235 | needs: test 236 | if: ${{ always() }} 237 | runs-on: ubuntu-latest 238 | steps: 239 | - uses: coverallsapp/github-action@v2 240 | with: 241 | parallel-finished: true 242 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # Temp files 5 | .*.sw[po] 6 | *~ 7 | *.bak 8 | .DS_Store 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Build and package files 14 | *.egg 15 | *.egg-info 16 | .bootstrap 17 | .build 18 | .cache 19 | .eggs 20 | .env 21 | .installed.cfg 22 | .ve 23 | bin 24 | build 25 | develop-eggs 26 | dist 27 | eggs 28 | lib 29 | lib64 30 | parts 31 | pip-wheel-metadata/ 32 | pyvenv*/ 33 | sdist 34 | var 35 | venv*/ 36 | wheelhouse 37 | 38 | # Installer logs 39 | pip-log.txt 40 | 41 | # Unit test / coverage reports 42 | .benchmarks 43 | .coverage 44 | .coverage.* 45 | .pytest 46 | .pytest_cache/ 47 | .tox 48 | coverage.xml 49 | htmlcov 50 | nosetests.xml 51 | 52 | # Translations 53 | *.mo 54 | 55 | # Buildout 56 | .mr.developer.cfg 57 | 58 | # IDE project files 59 | *.iml 60 | *.komodoproject 61 | .idea 62 | .project 63 | .pydevproject 64 | .vscode 65 | 66 | # Complexity 67 | output/*.html 68 | output/*/index.html 69 | 70 | # Sphinx 71 | docs/_build 72 | 73 | # Mypy Cache 74 | .mypy_cache/ 75 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # To install the git pre-commit hooks run: 2 | # pre-commit install --install-hooks 3 | # To update the versions: 4 | # pre-commit autoupdate 5 | exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg)(/|$)' 6 | # Note the order is intentional to avoid multiple passes of the hooks 7 | repos: 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.4.4 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --exit-non-zero-on-fix, --show-fixes, --unsafe-fixes] 13 | - repo: https://github.com/psf/black 14 | rev: 24.4.2 15 | hooks: 16 | - id: black 17 | - repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: v4.6.0 19 | hooks: 20 | - id: trailing-whitespace 21 | - id: end-of-file-fixer 22 | - id: debug-statements 23 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | version: 2 3 | sphinx: 4 | configuration: docs/conf.py 5 | formats: all 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3" 10 | python: 11 | install: 12 | - requirements: docs/requirements.txt 13 | - method: pip 14 | path: . 15 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | * Ionel Cristian Mărieș - https://blog.ionelmc.ro 6 | * Mithun Ayachit - https://github.com/mithun 7 | * Dan Ailenei - https://github.com/Dan-Ailenei 8 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 5.1.1 (2024-05-21) 6 | ------------------ 7 | 8 | * Do not display an authentication mask for http protocols if there are no actual credentials specified. 9 | 10 | 5.1.0 (2024-04-12) 11 | ------------------ 12 | 13 | * Fixed buggy handling when http checks are specified with a port. 14 | * Changed User-Agent header and stripped port from Host header for http checks. 15 | * Refactored a bunch of code into a separate ``holdup.checks`` module. 16 | 17 | 5.0.0 (2024-04-11) 18 | ------------------ 19 | 20 | * Added a static binary in the Github release (built with Pyinstaller on Alpine, as a static bin). 21 | * Dropped support for Python 3.7 and added in Python 3.12 in the test suite. 22 | 23 | 4.0.0 (2023-02-14) 24 | ------------------ 25 | 26 | * Added support for psycopg 3 (now the ``holdup[pg]`` extra will require that). The old psycopg2 is still supported for now. 27 | * Dropped support for Python 3.6 and added in Python 3.11 in the test suite. 28 | 29 | 3.0.0 (2022-03-20) 30 | ------------------ 31 | 32 | * Dropped support for Python 2. 33 | * Switched CI from Travis to GitHub Actions. 34 | * Fixed bugs with password masking (it wasn't working for postgresql URIs). 35 | 36 | 2.0.0 (2021-04-08) 37 | ------------------ 38 | 39 | * Added support for password masking (``--verbose-passwords`` to disable this feature). 40 | * Overhauled checks display a bit, output might be slightly different. 41 | * Added support for basic and digest HTTP authentication. 42 | * Published Docker image at https://hub.docker.com/r/ionelmc/holdup (Alpine based). 43 | 44 | 1.9.0 (2021-01-11) 45 | ------------------ 46 | 47 | * Added a ``--version`` argument. 48 | * Changed verbose output to mask passwords in postgresql checks. 49 | 50 | 1.8.1 (2020-12-16) 51 | ------------------ 52 | 53 | * Add support for PostgreSQL 12+ clients (strict integer type-checking on ``connect_timeout``). The float is now converted to an integer. 54 | 55 | 1.8.0 (2019-05-28) 56 | ------------------ 57 | 58 | * Added a PostgreSQL check. It handles the ``the database system is starting up`` problem. 59 | Contributed by Dan Ailenei in :pr:`6`. 60 | * Changed output so it's more clear and more brief: 61 | 62 | * arguments (checks) are quoted when printed, 63 | * "any" checks give exact info about what made it pass, 64 | * repetitive information is removed. 65 | * Simplified the internals for the "AnyCheck". 66 | 67 | 1.7.0 (2018-11-24) 68 | ------------------ 69 | 70 | * Added support for skipping SSL certificate verification for HTTPS services 71 | (the ``--insecure`` option and ``https+insecure`` protocol). 72 | Contributed by Mithun Ayachit in :pr:`2`. 73 | 74 | 1.6.0 (2018-03-22) 75 | ------------------ 76 | 77 | * Added verbose mode (`-v` or ``--verbose``). 78 | * Changed default timeout to 60s (from 5s). 79 | 80 | 1.5.0 (2017-06-07) 81 | ------------------ 82 | 83 | * Added an ``eval://expression`` protocol for weird user-defined checks. 84 | 85 | 1.4.0 (2017-03-27) 86 | ------------------ 87 | 88 | * Added support for HTTP(S) check. 89 | 90 | 1.3.0 (2017-02-21) 91 | ------------------ 92 | 93 | * Add support for "any" service check (service syntax with comma). 94 | 95 | 1.2.1 (2016-06-17) 96 | ------------------ 97 | 98 | * Handle situation where internal operations would take more than planned. 99 | 100 | 1.2.0 (2016-05-25) 101 | ------------------ 102 | 103 | * Added a file check. 104 | 105 | 1.1.0 (2016-05-06) 106 | ------------------ 107 | 108 | * Removed debug print. 109 | * Added ``--interval`` option for how often to check. No more spinloops. 110 | 111 | 1.0.0 (2016-04-22) 112 | ------------------ 113 | 114 | * Improved tests. 115 | * Always log to stderr. 116 | 117 | 0.1.0 (2016-04-21) 118 | ------------------ 119 | 120 | * First release on PyPI. 121 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | Holdup could always use more documentation, whether as part of the 21 | official Holdup docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/ionelmc/python-holdup/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `python-holdup` for local development: 39 | 40 | 1. Fork `python-holdup `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:YOURGITHUBNAME/python-holdup.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes run all the checks and docs builder with one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``). 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | Tips 77 | ---- 78 | 79 | To run a subset of tests:: 80 | 81 | tox -e envname -- pytest -k test_myfeature 82 | 83 | To run all the test environments in *parallel*:: 84 | 85 | tox -p auto 86 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.2.1 2 | FROM alpine:latest as dist 3 | 4 | RUN apk add --no-cache --virtual build-deps gcc python3-dev musl-dev py3-pip py3-wheel postgresql-dev 5 | RUN mkdir -p /build/dist 6 | WORKDIR /build 7 | RUN pip wheel --wheel-dir=dist psycopg[c] 8 | ADD . /build 9 | RUN python3 setup.py bdist_wheel 10 | 11 | FROM alpine:latest 12 | RUN apk add --no-cache py3-pip libpq 13 | RUN --mount=type=bind,from=dist,src=/build/dist,target=/dist \ 14 | pip install --break-system-packages /dist/* 15 | 16 | ENTRYPOINT ["holdup"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2016-2024, Ionel Cristian Mărieș. All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the 6 | following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following 9 | disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 15 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 17 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 18 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 19 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 20 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft src 3 | graft ci 4 | graft tests 5 | 6 | include .bumpversion.cfg 7 | include .cookiecutterrc 8 | include .coveragerc 9 | include .dockerignore 10 | include .editorconfig 11 | include .github/workflows/github-actions.yml 12 | include .pre-commit-config.yaml 13 | include .readthedocs.yml 14 | 15 | include AUTHORS.rst 16 | include CHANGELOG.rst 17 | include CONTRIBUTING.rst 18 | include Dockerfile 19 | include LICENSE 20 | include holdup.spec 21 | include pyinstaller.Dockerfile 22 | include pytest.ini 23 | include README.rst 24 | include tox.ini 25 | 26 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 27 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | .. start-badges 6 | 7 | .. list-table:: 8 | :stub-columns: 1 9 | 10 | * - docs 11 | - |docs| 12 | * - tests 13 | - |github-actions| |coveralls| 14 | * - package 15 | - |version| |wheel| |supported-versions| |supported-implementations| |commits-since| 16 | .. |docs| image:: https://readthedocs.org/projects/python-holdup/badge/?style=flat 17 | :target: https://readthedocs.org/projects/python-holdup/ 18 | :alt: Documentation Status 19 | 20 | .. |github-actions| image:: https://github.com/ionelmc/python-holdup/actions/workflows/github-actions.yml/badge.svg 21 | :alt: GitHub Actions Build Status 22 | :target: https://github.com/ionelmc/python-holdup/actions 23 | 24 | .. |coveralls| image:: https://coveralls.io/repos/github/ionelmc/python-holdup/badge.svg?branch=master 25 | :alt: Coverage Status 26 | :target: https://coveralls.io/github/ionelmc/python-holdup?branch=master 27 | 28 | .. |version| image:: https://img.shields.io/pypi/v/holdup.svg 29 | :alt: PyPI Package latest release 30 | :target: https://pypi.org/project/holdup 31 | 32 | .. |wheel| image:: https://img.shields.io/pypi/wheel/holdup.svg 33 | :alt: PyPI Wheel 34 | :target: https://pypi.org/project/holdup 35 | 36 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/holdup.svg 37 | :alt: Supported versions 38 | :target: https://pypi.org/project/holdup 39 | 40 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/holdup.svg 41 | :alt: Supported implementations 42 | :target: https://pypi.org/project/holdup 43 | 44 | .. |commits-since| image:: https://img.shields.io/github/commits-since/ionelmc/python-holdup/v5.1.1.svg 45 | :alt: Commits since latest release 46 | :target: https://github.com/ionelmc/python-holdup/compare/v5.1.1...master 47 | 48 | 49 | 50 | .. end-badges 51 | 52 | A tool to wait for services and execute command. Useful for Docker containers that depend on slow to start services 53 | (like almost everything). 54 | 55 | * Free software: BSD 2-Clause License 56 | 57 | Installation 58 | ============ 59 | 60 | Currently holdup is only published to PyPI and `hub.docker.com `_. 61 | 62 | To install from PyPI:: 63 | 64 | pip install holdup 65 | 66 | It has no dependencies except the optional PostgreSQL check support, which you'd install with:: 67 | 68 | pip install 'holdup[pg]' 69 | 70 | You can also install the in-development version with:: 71 | 72 | pip install https://github.com/ionelmc/python-holdup/archive/master.zip 73 | 74 | Alternate installation (Docker image) 75 | ------------------------------------- 76 | 77 | Example:: 78 | 79 | docker run --rm ionelmc/holdup tcp://foobar:1234 80 | 81 | Note that this will have some limitations: 82 | 83 | * executing the ``command`` is pretty pointless because holdup will run in its own container 84 | * you'll probably need extra network configuration to be able to access services 85 | * you won't be able to use `docker run` inside a container without exposing a docker daemon in said container 86 | 87 | 88 | Usage 89 | ===== 90 | 91 | usage: holdup [-h] [-t SECONDS] [-T SECONDS] [-i SECONDS] [-n] service [service ...] [-- command [arg [arg ...]]] 92 | 93 | Wait for services to be ready and optionally exec command. 94 | 95 | positional arguments: 96 | service 97 | A service to wait for. Supported protocols: "tcp://host:port/", "path:///path/to/something", "unix:///path/to/domain.sock", "eval://expr", "pg://user:password@host:port/dbname" ("postgres" and "postgresql" also allowed), "http://urn", "https://urn", "https+insecure://urn" (status 200 expected for http*). Join protocols with a comma to make holdup exit at the first passing one, eg: "tcp://host:1,host:2" or "tcp://host:1,tcp://host:2" are equivalent and mean `any that pass`. 98 | command 99 | An optional command to exec. 100 | 101 | optional arguments: 102 | -h, --help show this help message and exit 103 | -t SECONDS, --timeout SECONDS 104 | Time to wait for services to be ready. Default: 60.0 105 | -T SECONDS, --check-timeout SECONDS 106 | Time to wait for a single check. Default: 1.0 107 | -i SECONDS, --interval SECONDS 108 | How often to check. Default: 0.2 109 | -v, --verbose Verbose mode. 110 | --verbose-passwords Disable PostgreSQL/HTTP password masking. 111 | -n, --no-abort Ignore failed services. This makes `holdup` return 0 exit code regardless of services actually responding. 112 | --insecure Disable SSL Certificate verification for HTTPS services. 113 | --version display the version of the holdup package and its location, then exit. 114 | 115 | Example:: 116 | 117 | holdup tcp://foobar:1234 -- django-admin ... 118 | 119 | Documentation 120 | ============= 121 | 122 | https://python-holdup.readthedocs.io/ 123 | 124 | Development 125 | =========== 126 | 127 | To run all the tests run:: 128 | 129 | tox 130 | 131 | Note, to combine the coverage data from all the tox environments run: 132 | 133 | .. list-table:: 134 | :widths: 10 90 135 | :stub-columns: 1 136 | 137 | - - Windows 138 | - :: 139 | 140 | set PYTEST_ADDOPTS=--cov-append 141 | tox 142 | 143 | - - Other 144 | - :: 145 | 146 | PYTEST_ADDOPTS=--cov-append tox 147 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import pathlib 4 | import subprocess 5 | import sys 6 | 7 | base_path: pathlib.Path = pathlib.Path(__file__).resolve().parent.parent 8 | templates_path = base_path / "ci" / "templates" 9 | 10 | 11 | def check_call(args): 12 | print("+", *args) 13 | subprocess.check_call(args) 14 | 15 | 16 | def exec_in_env(): 17 | env_path = base_path / ".tox" / "bootstrap" 18 | if sys.platform == "win32": 19 | bin_path = env_path / "Scripts" 20 | else: 21 | bin_path = env_path / "bin" 22 | if not env_path.exists(): 23 | import subprocess 24 | 25 | print(f"Making bootstrap env in: {env_path} ...") 26 | try: 27 | check_call([sys.executable, "-m", "venv", env_path]) 28 | except subprocess.CalledProcessError: 29 | try: 30 | check_call([sys.executable, "-m", "virtualenv", env_path]) 31 | except subprocess.CalledProcessError: 32 | check_call(["virtualenv", env_path]) 33 | print("Installing `jinja2` into bootstrap environment...") 34 | check_call([bin_path / "pip", "install", "jinja2", "tox"]) 35 | python_executable = bin_path / "python" 36 | if not python_executable.exists(): 37 | python_executable = python_executable.with_suffix(".exe") 38 | 39 | print(f"Re-executing with: {python_executable}") 40 | print("+ exec", python_executable, __file__, "--no-env") 41 | os.execv(python_executable, [python_executable, __file__, "--no-env"]) 42 | 43 | 44 | def main(): 45 | import jinja2 46 | 47 | print(f"Project path: {base_path}") 48 | 49 | jinja = jinja2.Environment( 50 | loader=jinja2.FileSystemLoader(str(templates_path)), 51 | trim_blocks=True, 52 | lstrip_blocks=True, 53 | keep_trailing_newline=True, 54 | ) 55 | tox_environments = [ 56 | line.strip() 57 | # 'tox' need not be installed globally, but must be importable 58 | # by the Python that is running this script. 59 | # This uses sys.executable the same way that the call in 60 | # cookiecutter-pylibrary/hooks/post_gen_project.py 61 | # invokes this bootstrap.py itself. 62 | for line in subprocess.check_output([sys.executable, "-m", "tox", "--listenvs"], universal_newlines=True).splitlines() 63 | ] 64 | tox_environments = [line for line in tox_environments if line.startswith("py")] 65 | for template in templates_path.rglob("*"): 66 | if template.is_file(): 67 | template_path = template.relative_to(templates_path).as_posix() 68 | destination = base_path / template_path 69 | destination.parent.mkdir(parents=True, exist_ok=True) 70 | destination.write_text(jinja.get_template(template_path).render(tox_environments=tox_environments)) 71 | print(f"Wrote {template_path}") 72 | print("DONE.") 73 | 74 | 75 | if __name__ == "__main__": 76 | args = sys.argv[1:] 77 | if args == ["--no-env"]: 78 | main() 79 | elif not args: 80 | exec_in_env() 81 | else: 82 | print(f"Unexpected arguments: {args}", file=sys.stderr) 83 | sys.exit(1) 84 | -------------------------------------------------------------------------------- /ci/requirements.txt: -------------------------------------------------------------------------------- 1 | virtualenv>=16.6.0 2 | pip>=19.1.1 3 | setuptools>=18.0.1 4 | six>=1.14.0 5 | tox 6 | twine 7 | -------------------------------------------------------------------------------- /ci/templates/.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | test: 5 | name: {{ '${{ matrix.name }}' }} 6 | runs-on: {{ '${{ matrix.os }}' }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.11' 14 | toxpython: 'python3.11' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | - name: 'docs' 18 | python: '3.11' 19 | toxpython: 'python3.11' 20 | tox_env: 'docs' 21 | os: 'ubuntu-latest' 22 | {% for env in tox_environments %} 23 | {% set prefix = env.split('-')[0] -%} 24 | {% if prefix.startswith('pypy') %} 25 | {% set python %}pypy-{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 26 | {% set cpython %}pp{{ prefix[4:5] }}{% endset %} 27 | {% set toxpython %}pypy{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 28 | {% else %} 29 | {% set python %}{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 30 | {% set cpython %}cp{{ prefix[2:] }}{% endset %} 31 | {% set toxpython %}python{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 32 | {% endif %} 33 | {% for os, python_arch in [ 34 | ['ubuntu', 'x64'], 35 | ['macos', 'arm64'], 36 | ] %} 37 | - name: '{{ env }} ({{ os }})' 38 | python: '{{ python }}' 39 | toxpython: '{{ toxpython }}' 40 | python_arch: '{{ python_arch }}' 41 | tox_env: '{{ env if os == 'ubuntu' else prefix }}' 42 | os: '{{ os }}-latest' 43 | {% endfor %} 44 | {% endfor %} 45 | steps: 46 | - uses: actions/checkout@v4 47 | with: 48 | fetch-depth: 0 49 | - uses: actions/setup-python@v5 50 | with: 51 | python-version: {{ '${{ matrix.python }}' }} 52 | architecture: {{ '${{ matrix.python_arch }}' }} 53 | - name: install dependencies 54 | run: | 55 | python -mpip install --progress-bar=off -r ci/requirements.txt 56 | virtualenv --version 57 | pip --version 58 | tox --version 59 | pip list --format=freeze 60 | - name: test 61 | env: 62 | TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' 63 | run: > 64 | tox -e {{ '${{ matrix.tox_env }}' }} -v 65 | finish: 66 | needs: test 67 | if: {{ '${{ always() }}' }} 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: coverallsapp/github-action@v2 71 | with: 72 | parallel-finished: true 73 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | extensions = [ 2 | "sphinx.ext.autodoc", 3 | "sphinx.ext.autosummary", 4 | "sphinx.ext.coverage", 5 | "sphinx.ext.doctest", 6 | "sphinx.ext.extlinks", 7 | "sphinx.ext.ifconfig", 8 | "sphinx.ext.napoleon", 9 | "sphinx.ext.todo", 10 | "sphinx.ext.viewcode", 11 | ] 12 | source_suffix = ".rst" 13 | master_doc = "index" 14 | project = "Holdup" 15 | year = "2016-2024" 16 | author = "Ionel Cristian Mărieș" 17 | copyright = f"{year}, {author}" 18 | version = release = "5.1.1" 19 | 20 | pygments_style = "trac" 21 | templates_path = ["."] 22 | extlinks = { 23 | "issue": ("https://github.com/ionelmc/python-holdup/issues/%s", "#%s"), 24 | "pr": ("https://github.com/ionelmc/python-holdup/pull/%s", "PR #%s"), 25 | } 26 | html_theme = "furo" 27 | 28 | html_theme_options = { 29 | "githuburl": "https://github.com/ionelmc/python-holdup/", 30 | } 31 | 32 | html_use_smartypants = True 33 | html_last_updated_fmt = "%b %d, %Y" 34 | html_split_index = False 35 | html_short_title = f"{project}-{version}" 36 | 37 | napoleon_use_ivar = True 38 | napoleon_use_rtype = False 39 | napoleon_use_param = False 40 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Contents 3 | ======== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | readme 9 | reference/index 10 | contributing 11 | authors 12 | changelog 13 | 14 | Indices and tables 15 | ================== 16 | 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | * :ref:`search` 20 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | holdup.checks 5 | ------------- 6 | 7 | .. testsetup:: 8 | 9 | from holdup import * 10 | 11 | .. automodule:: holdup.checks 12 | :members: 13 | :undoc-members: 14 | :special-members: __init__, __len__ 15 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.3 2 | furo 3 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | builtin 2 | builtins 3 | classmethod 4 | staticmethod 5 | classmethods 6 | staticmethods 7 | args 8 | kwargs 9 | callstack 10 | Changelog 11 | Indices 12 | -------------------------------------------------------------------------------- /holdup.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ['src/holdup/__main__.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[], 9 | hiddenimports=['psycopg', 'psycopg_binary'], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | ) 16 | pyz = PYZ(a.pure) 17 | 18 | exe = EXE( 19 | pyz, 20 | a.scripts, 21 | a.binaries, 22 | a.datas, 23 | [], 24 | name='holdup', 25 | debug=False, 26 | bootloader_ignore_signals=False, 27 | strip=False, 28 | upx=True, 29 | upx_exclude=[], 30 | runtime_tmpdir=None, 31 | console=True, 32 | disable_windowed_traceback=False, 33 | argv_emulation=False, 34 | target_arch=None, 35 | codesign_identity=None, 36 | entitlements_file=None, 37 | ) 38 | -------------------------------------------------------------------------------- /pyinstaller.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest as build 2 | 3 | RUN apk add --no-cache --virtual build-deps gcc python3-dev musl-dev py3-pip py3-wheel postgresql-dev scons patchelf 4 | RUN mkdir -p /build/dist 5 | WORKDIR /build 6 | RUN pip install --break-system-packages pyinstaller psycopg[binary] staticx 7 | ADD . /build 8 | RUN python3 setup.py bdist_wheel 9 | RUN pyinstaller holdup.spec 10 | RUN staticx /build/dist/holdup /build/dist/holdup-static 11 | RUN /build/dist/holdup --help 12 | RUN /build/dist/holdup-static --help 13 | 14 | FROM scratch 15 | COPY --from=build /build/dist/holdup-static /holdup 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=30.3.0", 4 | ] 5 | 6 | [tool.ruff] 7 | extend-exclude = ["static", "ci/templates"] 8 | line-length = 140 9 | src = ["src", "tests"] 10 | target-version = "py38" 11 | 12 | [tool.ruff.lint.per-file-ignores] 13 | "ci/*" = ["S"] 14 | 15 | [tool.ruff.lint] 16 | ignore = [ 17 | "RUF001", # ruff-specific rules ambiguous-unicode-character-string 18 | "S101", # flake8-bandit assert 19 | "S308", # flake8-bandit suspicious-mark-safe-usage 20 | "S603", # flake8-bandit subprocess-without-shell-equals-true 21 | "S607", # flake8-bandit start-process-with-partial-path 22 | "E501", # pycodestyle line-too-long 23 | ] 24 | select = [ 25 | "B", # flake8-bugbear 26 | "C4", # flake8-comprehensions 27 | "DTZ", # flake8-datetimez 28 | "E", # pycodestyle errors 29 | "EXE", # flake8-executable 30 | "F", # pyflakes 31 | "I", # isort 32 | "INT", # flake8-gettext 33 | "PIE", # flake8-pie 34 | "PLC", # pylint convention 35 | "PLE", # pylint errors 36 | "PT", # flake8-pytest-style 37 | "PTH", # flake8-use-pathlib 38 | "Q", # flake8-quotes 39 | "RSE", # flake8-raise 40 | "RUF", # ruff-specific rules 41 | "S", # flake8-bandit 42 | "UP", # pyupgrade 43 | "W", # pycodestyle warnings 44 | ] 45 | 46 | [tool.ruff.lint.flake8-pytest-style] 47 | fixture-parentheses = false 48 | mark-parentheses = false 49 | 50 | [tool.ruff.lint.isort] 51 | forced-separate = ["conftest"] 52 | force-single-line = true 53 | 54 | [tool.black] 55 | line-length = 140 56 | target-version = ["py38"] 57 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # If a pytest section is found in one of the possible config files 3 | # (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, 4 | # so if you add a pytest config section elsewhere, 5 | # you will need to delete this section from setup.cfg. 6 | norecursedirs = 7 | migrations 8 | 9 | python_files = 10 | test_*.py 11 | *_test.py 12 | tests.py 13 | addopts = 14 | -ra 15 | --strict-markers 16 | --doctest-modules 17 | --doctest-glob=\*.rst 18 | --tb=short 19 | testpaths = 20 | tests 21 | markers = 22 | unit: regular tests 23 | func: pita tests 24 | # Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors 25 | filterwarnings = 26 | error 27 | # You can add exclusions, some examples: 28 | # ignore:'holdup' defines default_app_config:PendingDeprecationWarning:: 29 | # ignore:The {{% if::: 30 | # ignore:Coverage disabled via --no-cov switch! 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | from pathlib import Path 4 | 5 | from setuptools import find_packages 6 | from setuptools import setup 7 | 8 | 9 | def read(*names, **kwargs): 10 | with Path(__file__).parent.joinpath(*names).open(encoding=kwargs.get("encoding", "utf8")) as fh: 11 | return fh.read() 12 | 13 | 14 | setup( 15 | name="holdup", 16 | version="5.1.1", 17 | license="BSD-2-Clause", 18 | description="A tool to wait for services and execute command. Useful for Docker containers that depend on slow to start services (like almost everything).", 19 | long_description="{}\n{}".format( 20 | re.compile("^.. start-badges.*^.. end-badges", re.M | re.S).sub("", read("README.rst")), 21 | re.sub(":[a-z]+:`~?(.*?)`", r"``\1``", read("CHANGELOG.rst")), 22 | ), 23 | author="Ionel Cristian Mărieș", 24 | author_email="contact@ionelmc.ro", 25 | url="https://github.com/ionelmc/python-holdup", 26 | packages=find_packages("src"), 27 | package_dir={"": "src"}, 28 | py_modules=[path.stem for path in Path("src").glob("*.py")], 29 | include_package_data=True, 30 | zip_safe=False, 31 | classifiers=[ 32 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 33 | "Development Status :: 5 - Production/Stable", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: BSD License", 36 | "Operating System :: Unix", 37 | "Operating System :: POSIX", 38 | "Operating System :: Microsoft :: Windows", 39 | "Programming Language :: Python", 40 | "Programming Language :: Python :: 3", 41 | "Programming Language :: Python :: 3 :: Only", 42 | "Programming Language :: Python :: 3.8", 43 | "Programming Language :: Python :: 3.9", 44 | "Programming Language :: Python :: 3.10", 45 | "Programming Language :: Python :: 3.11", 46 | "Programming Language :: Python :: 3.12", 47 | "Programming Language :: Python :: Implementation :: CPython", 48 | "Programming Language :: Python :: Implementation :: PyPy", 49 | # uncomment if you test on these interpreters: 50 | # 'Programming Language :: Python :: Implementation :: IronPython', 51 | # 'Programming Language :: Python :: Implementation :: Jython', 52 | # 'Programming Language :: Python :: Implementation :: Stackless', 53 | "Topic :: Utilities", 54 | ], 55 | project_urls={ 56 | "Documentation": "https://python-holdup.readthedocs.io/", 57 | "Changelog": "https://python-holdup.readthedocs.io/en/latest/changelog.html", 58 | "Issue Tracker": "https://github.com/ionelmc/python-holdup/issues", 59 | }, 60 | keywords=["wait", "port", "service", "docker", "unix", "domain", "socket", "tcp", "waiter", "holdup", "hold-up"], 61 | python_requires=">=3.8", 62 | install_requires=[ 63 | # eg: 'aspectlib==1.1.1', 'six>=1.7', 64 | ], 65 | extras_require={ 66 | "pg": ["psycopg"], 67 | }, 68 | entry_points={ 69 | "console_scripts": [ 70 | "holdup = holdup.cli:main", 71 | ] 72 | }, 73 | ) 74 | -------------------------------------------------------------------------------- /src/holdup/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "5.1.1" 2 | -------------------------------------------------------------------------------- /src/holdup/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entrypoint module, in case you use `python -mholdup`. 3 | 4 | 5 | Why does this file exist, and why __main__? For more info, read: 6 | 7 | - https://www.python.org/dev/peps/pep-0338/ 8 | - https://docs.python.org/2/using/cmdline.html#cmdoption-m 9 | - https://docs.python.org/3/using/cmdline.html#cmdoption-m 10 | """ 11 | 12 | import sys 13 | 14 | from holdup.cli import main 15 | 16 | if __name__ == "__main__": 17 | sys.exit(main()) 18 | -------------------------------------------------------------------------------- /src/holdup/checks.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import ast 3 | import builtins 4 | import os 5 | import re 6 | import socket 7 | import ssl 8 | import sys 9 | from contextlib import closing 10 | from operator import methodcaller 11 | from urllib.parse import urlparse 12 | from urllib.parse import urlunparse 13 | from urllib.request import HTTPBasicAuthHandler 14 | from urllib.request import HTTPDigestAuthHandler 15 | from urllib.request import HTTPPasswordMgrWithDefaultRealm 16 | from urllib.request import HTTPSHandler 17 | from urllib.request import Request 18 | from urllib.request import build_opener 19 | 20 | from . import __version__ 21 | from .pg import psycopg 22 | 23 | 24 | class Check: 25 | error = None 26 | 27 | def is_passing(self, options): 28 | try: 29 | self.run(options) 30 | except Exception as exc: 31 | self.error = exc 32 | else: 33 | self.error = False 34 | if options.verbose: 35 | print(f"holdup: Passed check: {self.display(verbose=True, verbose_passwords=options.verbose_passwords)}") 36 | return True 37 | 38 | def run(self, options): 39 | raise NotImplementedError 40 | 41 | @property 42 | def status(self): 43 | if self.error: 44 | return f"{self.error}" 45 | elif self.error is None: 46 | return "PENDING" 47 | else: 48 | return "PASSED" 49 | 50 | def __repr__(self): 51 | return f"{self.__class__.__name__}({repr(self.__dict__)[1:-1]})" 52 | 53 | def display_definition(self, **kwargs): 54 | raise NotImplementedError 55 | 56 | def display(self, *, verbose, **kwargs): 57 | definition = self.display_definition(**kwargs) 58 | if verbose: 59 | return f"{definition!r} -> {self.status}" 60 | else: 61 | return definition 62 | 63 | 64 | class TcpCheck(Check): 65 | def __init__(self, host, port): 66 | self.host = host 67 | self.port = port 68 | 69 | def run(self, options): 70 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 71 | sock.settimeout(options.check_timeout) 72 | with closing(sock): 73 | sock.connect((self.host, self.port)) 74 | 75 | def __repr__(self): 76 | return f"TcpCheck(host={self.host!r}, port={self.port!r})" 77 | 78 | def display(self, *, verbose, **_): 79 | definition = f"tcp://{self.host}:{self.port}" 80 | if verbose: 81 | return f"{definition!r} -> {self.status}" 82 | else: 83 | return definition 84 | 85 | 86 | class PgCheck(Check): 87 | def __init__(self, connection_string): 88 | self.connection_string = connection_string 89 | if "?" in connection_string.rsplit("/", 1)[1]: 90 | self.separator = "&" 91 | else: 92 | self.separator = "?" 93 | 94 | def run(self, options): 95 | with closing( 96 | psycopg.connect(f"{self.connection_string}{self.separator}connect_timeout={max(1, int(options.check_timeout))}") 97 | ) as conn: 98 | with closing(conn.cursor()) as cur: 99 | cur.execute("SELECT version()") 100 | cur.fetchone() 101 | 102 | def __repr__(self): 103 | return f"PgCheck({self.connection_string})" 104 | 105 | def display_definition(self, *, verbose_passwords, _password_re=re.compile(r":[^@:]+@")): 106 | definition = str(self.connection_string) 107 | if not verbose_passwords: 108 | definition = _password_re.sub(":******@", definition, 1) 109 | return definition 110 | 111 | 112 | class HttpCheck(Check): 113 | def __init__(self, url): 114 | self.handlers = [] 115 | self.parsed_url = url = urlparse(url) 116 | self.scheme = url.scheme 117 | self.insecure = False 118 | if url.scheme == "https+insecure": 119 | self.insecure = True 120 | url = url._replace(scheme="https") 121 | 122 | if url.port: 123 | self.netloc = f"{url.hostname}:{url.port}" 124 | else: 125 | self.netloc = url.hostname 126 | self.host = url.hostname 127 | 128 | cleaned_url = urlunparse(url._replace(netloc=self.netloc)) 129 | 130 | if url.username or url.password: 131 | password_mgr = HTTPPasswordMgrWithDefaultRealm() 132 | password_mgr.add_password(None, cleaned_url, url.username, url.password) 133 | self.handlers.append(HTTPDigestAuthHandler(passwd=password_mgr)) 134 | self.handlers.append(HTTPBasicAuthHandler(password_mgr=password_mgr)) 135 | 136 | self.url = cleaned_url 137 | 138 | def run(self, options): 139 | handlers = list(self.handlers) 140 | insecure = self.insecure or options.insecure 141 | 142 | ssl_ctx = ssl.create_default_context() 143 | if insecure: 144 | ssl_ctx.check_hostname = False 145 | ssl_ctx.verify_mode = ssl.CERT_NONE 146 | handlers.append(HTTPSHandler(context=ssl_ctx)) 147 | 148 | opener = build_opener(*handlers) 149 | opener.addheaders = [("User-Agent", f"python-holdup/{__version__}")] 150 | request = Request(self.url, headers={"Host": self.host}) # noqa: S310 151 | with closing(opener.open(request, timeout=options.check_timeout)) as req: 152 | status = req.getcode() 153 | if status != 200: 154 | raise Exception(f"Expected status code 200, got {status!r}") 155 | 156 | def __repr__(self): 157 | return f"HttpCheck({self.url}, insecure={self.insecure}, status={self.status})" 158 | 159 | def display_definition(self, *, verbose_passwords): 160 | url = self.parsed_url 161 | if url.username and not verbose_passwords: 162 | if not url.password: 163 | mask = "******" 164 | else: 165 | mask = f"{url.username}:******" 166 | url = url._replace(netloc=f"{mask}@{self.netloc}") 167 | return urlunparse(url) 168 | 169 | 170 | class UnixCheck(Check): 171 | def __init__(self, path): 172 | self.path = path 173 | 174 | def run(self, options): 175 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 176 | sock.settimeout(options.check_timeout) 177 | with closing(sock): 178 | sock.connect(self.path) 179 | 180 | def __repr__(self): 181 | return f"UnixCheck({self.path!r}, status={self.status})" 182 | 183 | def display_definition(self, **_): 184 | return f"unix://{self.path}" 185 | 186 | 187 | class PathCheck(Check): 188 | def __init__(self, path): 189 | self.path = path 190 | 191 | def run(self, _): 192 | # necessary to check if it exists. 193 | os.stat(self.path) # noqa: PTH116 194 | if not os.access(self.path, os.R_OK): 195 | raise Exception(f"Failed access({self.path!r}, R_OK) test") 196 | 197 | def __repr__(self): 198 | return f"PathCheck({self.path!r}, status={self.status})" 199 | 200 | def display_definition(self, **_): 201 | return f"path://{self.path}" 202 | 203 | 204 | class EvalCheck(Check): 205 | def __init__(self, expr): 206 | self.expr = expr 207 | self.ns = {} 208 | try: 209 | tree = ast.parse(expr) 210 | except SyntaxError as exc: 211 | raise argparse.ArgumentTypeError( 212 | f'Invalid service spec {expr!r}. Parse error:\n {exc.text} {" " * exc.offset}^\n{exc}' 213 | ) from None 214 | for node in ast.walk(tree): 215 | if isinstance(node, ast.Name): 216 | if not hasattr(builtins, node.id): 217 | try: 218 | __import__(node.id) 219 | except ImportError as exc: 220 | raise argparse.ArgumentTypeError(f"Invalid service spec {expr!r}. Import error: {exc}") from None 221 | self.ns[node.id] = sys.modules[node.id] 222 | 223 | def run(self, _): 224 | result = eval(self.expr, dict(self.ns), dict(self.ns)) # noqa: S307 225 | if not result: 226 | raise Exception(f"Failed to evaluate {self.expr!r}. Result {result!r} is falsey") 227 | 228 | def __repr__(self): 229 | return f"EvalCheck({self.expr!r}, ns={self.ns!r}, status={self.status})" 230 | 231 | def display_definition(self, **_): 232 | return f"eval://{self.expr}" 233 | 234 | 235 | class AnyCheck(Check): 236 | def __init__(self, checks): 237 | self.checks = checks 238 | 239 | def run(self, options): 240 | for check in self.checks: 241 | if check.is_passing(options): 242 | break 243 | else: 244 | raise Exception("ALL FAILED") 245 | 246 | def __repr__(self): 247 | return f'AnyCheck({", ".join(map(repr, self.checks))}, status={self.status})' 248 | 249 | def display(self, *, verbose, **kwargs): 250 | checks = ", ".join(map(methodcaller("display", verbose=verbose, **kwargs), self.checks)) 251 | if verbose: 252 | return f"any({checks}) -> {self.status}" 253 | else: 254 | return f"any({checks})" 255 | -------------------------------------------------------------------------------- /src/holdup/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module that contains the command line app. 3 | 4 | Why does this file exist, and why not put this in __main__? 5 | 6 | You might be tempted to import things from __main__ later, but that will cause 7 | problems: the code will get executed twice: 8 | 9 | - When you run `python -mholdup` python will execute 10 | ``__main__.py`` as a script. That means there will not be any 11 | ``holdup.__main__`` in ``sys.modules``. 12 | - When you import __main__ it will get executed again (as a module) because 13 | there"s no ``holdup.__main__`` in ``sys.modules``. 14 | 15 | Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration 16 | """ 17 | 18 | import argparse 19 | import os 20 | import sys 21 | from operator import methodcaller 22 | from shlex import quote 23 | from time import sleep 24 | from time import time 25 | 26 | from . import __version__ 27 | from .checks import AnyCheck 28 | from .checks import EvalCheck 29 | from .checks import HttpCheck 30 | from .checks import PathCheck 31 | from .checks import PgCheck 32 | from .checks import TcpCheck 33 | from .checks import UnixCheck 34 | from .pg import make_conninfo 35 | from .pg import psycopg 36 | 37 | 38 | def parse_service(service): 39 | if "://" not in service: 40 | raise argparse.ArgumentTypeError(f'Invalid service spec {service!r}. Must have "://".') 41 | proto, value = service.split("://", 1) 42 | 43 | if "," in value and proto != "eval": 44 | parts = value.split(",") 45 | for pos, part in enumerate(parts): 46 | if part.startswith("eval://"): 47 | parts[pos] = ",".join(parts[pos:]) 48 | del parts[pos + 1 :] 49 | break 50 | return AnyCheck([parse_value(part, proto) for part in parts]) 51 | else: 52 | return parse_value(value, proto) 53 | 54 | 55 | def parse_value(value, proto): 56 | if "://" in value: 57 | proto, value = value.split("://", 1) 58 | display_value = f"{proto}://{value}" 59 | 60 | if proto == "tcp": 61 | if ":" not in value: 62 | raise argparse.ArgumentTypeError(f'Invalid service spec {display_value!r}. Must have ":". Where\'s the port?') 63 | host, port = value.strip("/").split(":", 1) 64 | if not port.isdigit(): 65 | raise argparse.ArgumentTypeError(f"Invalid service spec {display_value!r}. Port must be a number not {port!r}.") 66 | port = int(port) 67 | return TcpCheck(host, port) 68 | elif proto in ("pg", "postgresql", "postgres"): 69 | if psycopg is None: 70 | raise argparse.ArgumentTypeError(f"Protocol {proto} unusable. Install holdup[pg].") 71 | 72 | uri = f"postgresql://{value}" 73 | try: 74 | connection_uri = make_conninfo(uri) 75 | except Exception as exc: 76 | raise argparse.ArgumentTypeError(f"Failed to parse {display_value!r}: {exc}. Must be a valid connection URI.") from None 77 | return PgCheck(connection_uri) 78 | elif proto == "unix": 79 | return UnixCheck(value) 80 | elif proto == "path": 81 | return PathCheck(value) 82 | elif proto in ("http", "https", "https+insecure"): 83 | return HttpCheck(f"{proto}://{value}") 84 | elif proto == "eval": 85 | return EvalCheck(value) 86 | else: 87 | raise argparse.ArgumentTypeError(f'Unknown protocol {proto!r} in {display_value!r}. Must be "tcp", "path", "unix" or "pg".') 88 | 89 | 90 | parser = argparse.ArgumentParser( 91 | usage="%(prog)s [-h] [-t SECONDS] [-T SECONDS] [-i SECONDS] [-n] service [service ...] " "[-- command [arg [arg ...]]]", 92 | description="Wait for services to be ready and optionally exec command.", 93 | ) 94 | parser.add_argument( 95 | "service", 96 | nargs=argparse.ONE_OR_MORE, 97 | type=parse_service, 98 | help="A service to wait for. " 99 | "Supported protocols: " 100 | '"tcp://host:port/", ' 101 | '"path:///path/to/something", ' 102 | '"unix:///path/to/domain.sock", ' 103 | '"eval://expr", ' 104 | '"pg://user:password@host:port/dbname" ("postgres" and "postgresql" also allowed), ' 105 | '"http://urn", ' 106 | '"https://urn", ' 107 | '"https+insecure://urn" (status 200 expected for http*). ' 108 | "Join protocols with a comma to make holdup exit at the first " 109 | 'passing one, eg: "tcp://host:1,host:2" or "tcp://host:1,tcp://host:2" are equivalent and mean ' 110 | "`any that pass`.", 111 | ) 112 | parser.add_argument("command", nargs=argparse.OPTIONAL, help="An optional command to exec.") 113 | parser.add_argument( 114 | "-t", "--timeout", metavar="SECONDS", type=float, default=60.0, help="Time to wait for services to be ready. Default: %(default)s" 115 | ) 116 | parser.add_argument( 117 | "-T", "--check-timeout", metavar="SECONDS", type=float, default=1.0, help="Time to wait for a single check. Default: %(default)s" 118 | ) 119 | parser.add_argument("-i", "--interval", metavar="SECONDS", type=float, default=0.2, help="How often to check. Default: %(default)s") 120 | parser.add_argument("-v", "--verbose", action="store_true", help="Verbose mode.") 121 | parser.add_argument("--verbose-passwords", action="store_true", help="Disable PostgreSQL/HTTP password masking.") 122 | parser.add_argument( 123 | "-n", 124 | "--no-abort", 125 | action="store_true", 126 | help="Ignore failed services. " "This makes `holdup` return 0 exit code regardless of services actually responding.", 127 | ) 128 | parser.add_argument("--insecure", action="store_true", help="Disable SSL Certificate verification for HTTPS services.") 129 | 130 | 131 | def add_version_argument(parser): 132 | parser.add_argument( 133 | "--version", 134 | action="version", 135 | version=f"%(prog)s v{__version__}", 136 | help="display the version of the holdup package and its location, then exit.", 137 | ) 138 | 139 | 140 | def main(): 141 | """ 142 | Args: 143 | argv (list): List of arguments 144 | 145 | Returns: 146 | int: A return code 147 | 148 | Does stuff. 149 | """ 150 | add_version_argument(parser) 151 | if "--" in sys.argv: 152 | pos = sys.argv.index("--") 153 | argv, command = sys.argv[1:pos], sys.argv[pos + 1 :] 154 | else: 155 | argv, command = sys.argv[1:], None 156 | options = parser.parse_args(args=argv) 157 | if options.timeout < options.check_timeout: 158 | if options.check_timeout == 1.0: 159 | options.check_timeout = options.timeout 160 | else: 161 | parser.error("--timeout value must be greater than --check-timeout value!") 162 | pending = list(options.service) 163 | brief_representer = methodcaller("display", verbose=False, verbose_passwords=options.verbose_passwords) 164 | verbose_representer = methodcaller("display", verbose=True, verbose_passwords=options.verbose_passwords) 165 | if options.verbose: 166 | print( 167 | f"holdup: Waiting for {options.timeout}s ({options.check_timeout}s per check, {options.interval}s sleep between loops) " 168 | f'for these services: {", ".join(map(brief_representer, pending))}' 169 | ) 170 | start = time() 171 | at_least_once = True 172 | while at_least_once or pending and time() - start < options.timeout: 173 | lapse = time() 174 | pending = [check for check in pending if not check.is_passing(options)] 175 | sleep(max(0, options.interval - time() + lapse)) 176 | at_least_once = False 177 | 178 | if pending: 179 | if options.no_abort: 180 | print( 181 | f'holdup: Failed checks: {", ".join(map(verbose_representer, pending))}. Treating as success because of --no-abort.', 182 | file=sys.stderr, 183 | ) 184 | else: 185 | parser.exit(1, f'holdup: Failed checks: {", ".join(map(verbose_representer, pending))}. Aborting!\n') 186 | if command: 187 | if options.verbose: 188 | print(f'holdup: Executing: {" ".join(map(quote, command))}') 189 | os.execvp(command[0], command) # noqa:S606 190 | -------------------------------------------------------------------------------- /src/holdup/pg.py: -------------------------------------------------------------------------------- 1 | try: 2 | import psycopg 3 | except ImportError: 4 | try: 5 | import psycopg2 as psycopg 6 | except ImportError: 7 | try: 8 | import psycopg2cffi as psycopg 9 | except ImportError: 10 | psycopg = None 11 | 12 | try: 13 | from psycopg.conninfo import make_conninfo 14 | except ImportError: 15 | try: 16 | from psycopg2.extensions import make_dsn as make_conninfo 17 | except ImportError: 18 | make_conninfo = lambda value: value # noqa 19 | -------------------------------------------------------------------------------- /tests/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM buildpack-deps:20.04-scm AS deps 3 | 4 | ENV TZ=UTC 5 | # DEBIAN_FRONTEND=noninteractive exists to prevent tzdata going nuts. 6 | # Maybe dpkg incorrectly detects interactive on buildkit containers? 7 | RUN echo "deb http://apt.postgresql.org/pub/repos/apt focal-pgdg main 10" > /etc/apt/sources.list.d/pgdg.list \ 8 | && curl -fsSL11 'https://www.postgresql.org/media/keys/ACCC4CF8.asc' | apt-key add - \ 9 | && apt-get update \ 10 | && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 11 | python3-dev python3-distutils-extra \ 12 | libpq-dev=10.* libpq5=10.* \ 13 | build-essential git sudo ca-certificates 14 | # Avoid having to use python3 all over the place. 15 | RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.8 1 16 | 17 | RUN bash -o pipefail -c "curl -fsSL 'https://bootstrap.pypa.io/get-pip.py' | \ 18 | python - --no-cache --disable-pip-version-check --upgrade pip setuptools" 19 | 20 | RUN mkdir /wheels \ 21 | && pip wheel --no-cache --wheel-dir=/wheels psycopg2 22 | 23 | ################# 24 | ################# 25 | FROM ubuntu:20.04 26 | ################# 27 | RUN test -e /etc/apt/apt.conf.d/docker-clean # sanity check 28 | 29 | ENV TZ=UTC 30 | RUN apt-get update \ 31 | && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 32 | curl software-properties-common gpg-agent \ 33 | && echo "deb http://apt.postgresql.org/pub/repos/apt focal-pgdg main 10" > /etc/apt/sources.list.d/pgdg.list \ 34 | && curl -fsSL11 'https://www.postgresql.org/media/keys/ACCC4CF8.asc' | apt-key add - \ 35 | && apt-get update \ 36 | && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 37 | strace gdb lsof locate net-tools htop iputils-ping dnsutils \ 38 | nano vim tree less telnet \ 39 | redis-tools \ 40 | socat \ 41 | graphviz \ 42 | dumb-init \ 43 | libpq5=10.* postgresql-client-10 \ 44 | python3-dbg python3-distutils python3-distutils-extra \ 45 | libmemcached11 \ 46 | sudo ca-certificates \ 47 | gdal-bin python3-gdal 48 | # Avoid having to use python3 all over the place. 49 | RUN update-alternatives --install /usr/bin/python python /usr/bin/python3 1 50 | 51 | # Adds a new user named python and add it to the list of sudoers. Will be able to call sudo without the password. 52 | # This is more geared to development (eg: match user's UID) than production (where you shouln't need any sudo/home). 53 | RUN bash -o pipefail -c "curl -fsSL 'https://bootstrap.pypa.io/get-pip.py' | \ 54 | python - --no-cache --disable-pip-version-check --upgrade pip==22.0.3 setuptools==60.9.3" 55 | 56 | RUN mkdir /deps 57 | COPY --from=deps /wheels/* /deps/ 58 | RUN pip install --force-reinstall --ignore-installed --upgrade --no-index --no-deps /deps/* \ 59 | && rm -rf /deps \ 60 | && mkdir /app /var/app 61 | 62 | # Create django user, will own the Django app 63 | RUN adduser --no-create-home --disabled-login --group --system app 64 | RUN chown -R app:app /app /var/app 65 | 66 | ENV PYTHONUNBUFFERED=1 67 | RUN mkdir /project 68 | COPY setup.* *.rst MANIFEST.in /project/ 69 | COPY src /project/src 70 | RUN pip install /project 71 | COPY tests/test_pg.py / 72 | 73 | CMD ["true"] 74 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def pytest_collection_modifyitems(items): 5 | for item in items: 6 | if item.name.startswith("test_func"): 7 | item.add_marker(pytest.mark.func) 8 | else: 9 | item.add_marker(pytest.mark.unit) 10 | -------------------------------------------------------------------------------- /tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | services: 4 | pg: 5 | image: postgres:10 6 | environment: 7 | POSTGRES_USER: app 8 | POSTGRES_PASSWORD: app 9 | POSTGRES_DB: app 10 | POSTGRES_INITDB_ARGS: "--data-checksums" 11 | test: 12 | build: 13 | context: .. 14 | dockerfile: tests/Dockerfile 15 | volumes: 16 | - $PWD:/src 17 | -------------------------------------------------------------------------------- /tests/test_holdup.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: PTH110, PTH120, PTH123 2 | import os 3 | import platform 4 | import shutil 5 | import socket 6 | import threading 7 | 8 | import pytest 9 | 10 | pytest_plugins = ("pytester",) 11 | 12 | 13 | def has_docker(): 14 | return shutil.which("docker") 15 | 16 | 17 | @pytest.fixture(params=[[], ["--", "python", "-c", 'print("success !")']]) 18 | def extra(request): 19 | return request.param 20 | 21 | 22 | def test_normal(testdir, tmp_path_factory, extra): 23 | tcp = socket.socket() 24 | tcp.bind(("127.0.0.1", 0)) 25 | tcp.listen(1) 26 | _, port = tcp.getsockname() 27 | 28 | def accept(): 29 | conn, _ = tcp.accept() 30 | conn.close() 31 | 32 | t = threading.Thread(target=accept) 33 | t.start() 34 | 35 | uds = socket.socket(socket.AF_UNIX) 36 | 37 | tmp_path = tmp_path_factory.getbasetemp() 38 | unix_path = tmp_path / "sock" 39 | path_path = tmp_path / "file" 40 | 41 | with open(path_path, "w"): 42 | pass 43 | uds.bind(str(unix_path)) 44 | uds.listen(1) 45 | 46 | result = testdir.run("holdup", "-t", "0.5", f"tcp://localhost:{port}/", f"path://{path_path}", f"unix://{unix_path}", *extra) 47 | if extra: 48 | result.stdout.fnmatch_lines(["success !"]) 49 | assert result.ret == 0 50 | t.join() 51 | uds.close() 52 | tcp.close() 53 | unix_path.unlink() 54 | 55 | 56 | @pytest.mark.parametrize("status", [200, 404]) 57 | @pytest.mark.parametrize("proto", ["http", "https"]) 58 | def test_http(testdir, extra, status, proto): 59 | result = testdir.run("holdup", "-T", "5", "-t", "5.1", f"{proto}://httpbingo.org/status/{status}", *extra) 60 | if extra: 61 | if status == 200: 62 | result.stdout.fnmatch_lines(["success !"]) 63 | else: 64 | result.stderr.fnmatch_lines(["*HTTP Error 404*"]) 65 | 66 | 67 | @pytest.mark.parametrize("status", [200, 404]) 68 | def test_http_port(testdir, extra, status): 69 | result = testdir.run("holdup", "-T", "5", "-t", "5.1", f"http://httpbingo.org:80/status/{status}", *extra) 70 | if extra: 71 | if status == 200: 72 | result.stdout.fnmatch_lines(["success !"]) 73 | else: 74 | result.stderr.fnmatch_lines(["*HTTP Error 404*"]) 75 | 76 | 77 | @pytest.mark.parametrize("auth", ["basic-auth", "digest-auth/auth"]) 78 | @pytest.mark.parametrize("proto", ["http", "https"]) 79 | def test_http_auth(testdir, extra, auth, proto): 80 | result = testdir.run("holdup", "-T", "5", "-t", "5.1", f"{proto}://usr:pwd@httpbingo.org/{auth}/usr/pwd", *extra) 81 | if extra: 82 | result.stdout.fnmatch_lines(["success !"]) 83 | 84 | 85 | def test_http_insecure_with_option(testdir): 86 | result = testdir.run("holdup", "-t", "2", "--insecure", "https://self-signed.badssl.com/") 87 | assert result.ret == 0 88 | 89 | 90 | def test_http_insecure_with_proto(testdir): 91 | result = testdir.run("holdup", "-t", "2", "https+insecure://self-signed.badssl.com/") 92 | assert result.ret == 0 93 | 94 | 95 | def test_any1(testdir, tmp_path_factory, extra): 96 | tcp = socket.socket() 97 | tcp.bind(("127.0.0.1", 0)) 98 | _, port = tcp.getsockname() 99 | 100 | uds = socket.socket(socket.AF_UNIX) 101 | 102 | tmp_path = tmp_path_factory.getbasetemp() 103 | unix_path = tmp_path / "s" 104 | path_path = tmp_path / "miss" 105 | uds.bind(str(unix_path)) 106 | uds.listen(1) 107 | result = testdir.run("holdup", "-v", "-t", "0.5", f"tcp://localhost:{port}/,path://{path_path},unix://{unix_path}", *extra) 108 | if extra: 109 | result.stdout.fnmatch_lines( 110 | [ 111 | "holdup: Waiting for 0.5s (0.5s per check, 0.2s sleep between loops) for these services: " 112 | f"any(tcp://localhost:*, path://{path_path}, unix://{unix_path})", 113 | f"holdup: Passed check: 'unix://{unix_path}' -> PASSED", 114 | f"holdup: Passed check: any('tcp://localhost:*' -> *, 'path://{path_path}' ->" 115 | f" [[]Errno 2[]] No such file or directory: *, " 116 | f"'unix://{unix_path}' -> PASSED) -> PASSED", 117 | "holdup: Executing: python -c 'print(\"success !\")'", 118 | "success !", 119 | ] 120 | ) 121 | assert result.ret == 0 122 | tcp.close() 123 | uds.close() 124 | unix_path.unlink() 125 | 126 | 127 | def test_any2(testdir, tmp_path, extra): 128 | tcp = socket.socket() 129 | tcp.bind(("127.0.0.1", 0)) 130 | _, port = tcp.getsockname() 131 | 132 | uds = socket.socket(socket.AF_UNIX) 133 | unix_path = tmp_path / "s" 134 | path_path = tmp_path / "miss" 135 | uds.bind(str(unix_path)) 136 | uds.listen(1) 137 | result = testdir.run("holdup", "-v", "-t", "0.5", f"path://{path_path},unix://{unix_path},tcp://localhost:{port}/", *extra) 138 | if extra: 139 | result.stdout.fnmatch_lines( 140 | [ 141 | "holdup: Waiting for 0.5s (0.5s per check, 0.2s sleep between loops) for these services: " 142 | f"any(path://{path_path}, unix://{unix_path}, tcp://localhost:*)", 143 | f"holdup: Passed check: 'unix://{unix_path}' -> PASSED", 144 | f"holdup: Passed check: any('path://{path_path}' -> [[]Errno 2[]] No such file or directory: *, " 145 | f"'unix://{unix_path}' -> PASSED, 'tcp://localhost:*' -> PENDING) -> PASSED", 146 | "holdup: Executing: python -c 'print(\"success !\")'", 147 | "success !", 148 | ] 149 | ) 150 | assert result.ret == 0 151 | tcp.close() 152 | uds.close() 153 | unix_path.unlink() 154 | 155 | 156 | def test_any_same_proto(testdir, extra): 157 | tcp1 = socket.socket() 158 | tcp1.bind(("127.0.0.1", 0)) 159 | _, port1 = tcp1.getsockname() 160 | 161 | tcp2 = socket.socket() 162 | tcp2.bind(("127.0.0.1", 0)) 163 | tcp2.listen(1) 164 | _, port2 = tcp2.getsockname() 165 | 166 | def accept(): 167 | conn, _ = tcp2.accept() 168 | conn.close() 169 | 170 | t = threading.Thread(target=accept) 171 | t.start() 172 | 173 | result = testdir.run("holdup", "-t", "0.5", f"tcp://localhost:{port1},localhost:{port2}/", *extra) 174 | if extra: 175 | result.stdout.fnmatch_lines(["success !"]) 176 | assert result.ret == 0 177 | t.join() 178 | tcp1.close() 179 | tcp2.close() 180 | 181 | 182 | def test_any_failed(testdir): 183 | tcp = socket.socket() 184 | tcp.bind(("127.0.0.1", 0)) 185 | _, port = tcp.getsockname() 186 | 187 | result = testdir.run("holdup", "-t", "0.5", f"tcp://localhost:{port}/,path:///doesnt/exist,unix:///doesnt/exist") 188 | result.stderr.fnmatch_lines( 189 | [ 190 | f"holdup: Failed checks: any('tcp://localhost:{port}' -> *, 'path:///doesnt/exist' -> *, " 191 | "'unix:///doesnt/exist' -> *) -> ALL FAILED. " 192 | "Aborting!", 193 | ] 194 | ) 195 | tcp.close() 196 | 197 | 198 | def test_no_abort(testdir, extra): 199 | result = testdir.run( 200 | "holdup", "-t", "0.1", "-n", "tcp://localhost:0", "tcp://localhost:0/", "path:///doesnt/exist", "unix:///doesnt/exist", *extra 201 | ) 202 | result.stderr.fnmatch_lines( 203 | [ 204 | "holdup: Failed checks: 'tcp://localhost:0' -> *, " 205 | "'path:///doesnt/exist' -> *, 'unix:///doesnt/exist' -> *. " 206 | "Treating as success because of --no-abort.", 207 | ] 208 | ) 209 | 210 | 211 | @pytest.mark.skipif(os.path.exists("/.dockerenv"), reason="chmod(0) does not work in docker") 212 | def test_not_readable(testdir, extra): 213 | foobar = testdir.maketxtfile(foobar="") 214 | foobar.chmod(0) 215 | result = testdir.run("holdup", "-t", "0.1", "-n", f"path://{foobar}", *extra) 216 | result.stderr.fnmatch_lines( 217 | [ 218 | f"holdup: Failed checks: 'path://{foobar}' -> Failed access('{foobar}', R_OK) test. " 219 | "Treating as success because of --no-abort.", 220 | ] 221 | ) 222 | 223 | 224 | def test_bad_timeout(testdir): 225 | result = testdir.run("holdup", "-t", "0.1", "-T", "2", "path:///") 226 | result.stderr.fnmatch_lines(["*error: --timeout value must be greater than --check-timeout value!"]) 227 | 228 | 229 | def test_eval_bad_import(testdir): 230 | result = testdir.run("holdup", "eval://foobar123.foo()") 231 | result.stderr.fnmatch_lines( 232 | [ 233 | "*error: argument service: Invalid service spec 'foobar123.foo()'. Import error: No module named*", 234 | ] 235 | ) 236 | 237 | 238 | def test_eval_bad_expr(testdir): 239 | result = testdir.run("holdup", "eval://foobar123.foo(.)") 240 | result.stderr.fnmatch_lines( 241 | [ 242 | "*error: argument service: Invalid service spec 'foobar123.foo(.)'. Parse error:", 243 | " foobar123.foo(.)", 244 | "* ^", 245 | "invalid syntax (, line 1)", 246 | ] 247 | ) 248 | 249 | 250 | def test_eval_falsey(testdir): 251 | result = testdir.run("holdup", "-t", "0", "eval://None") 252 | result.stderr.fnmatch_lines(["holdup: Failed checks: 'eval://None' -> Failed to evaluate 'None'. Result None is falsey. Aborting!"]) 253 | assert result.ret == 1 254 | 255 | 256 | def test_eval_distutils(testdir, extra): 257 | result = testdir.run("holdup", 'eval://__import__("shutil").which("find")', *extra) 258 | if extra: 259 | result.stdout.fnmatch_lines(["success !"]) 260 | assert result.ret == 0 261 | 262 | 263 | def test_eval_comma(testdir, extra): 264 | result = testdir.run("holdup", 'eval://os.path.join("foo", "bar")', *extra) 265 | if extra: 266 | result.stdout.fnmatch_lines(["success !"]) 267 | assert result.ret == 0 268 | 269 | 270 | def test_eval_comma_anycheck(testdir, extra): 271 | result = testdir.run("holdup", 'path://whatever123,eval://os.path.join("foo", "bar")', *extra) 272 | if extra: 273 | result.stdout.fnmatch_lines(["success !"]) 274 | assert result.ret == 0 275 | 276 | 277 | @pytest.mark.skipif(platform.system() != "Linux", reason="too complicated to install psycopg on non-linux") 278 | @pytest.mark.parametrize("proto", ["postgresql", "postgres", "pg"]) 279 | def test_pg_timeout(testdir, proto): 280 | result = testdir.run("holdup", "-t", "0.1", proto + "://0.0.0.0/foo") 281 | result.stderr.fnmatch_lines( 282 | [ 283 | "holdup: Failed checks: 'postgresql://0.0.0.0/foo' -> *", 284 | ] 285 | ) 286 | 287 | 288 | @pytest.mark.parametrize("proto", ["postgresql", "postgres", "pg"]) 289 | def test_pg_unavailable(testdir, proto): 290 | testdir.tmpdir.join("psycopg2cffi").ensure(dir=1) 291 | testdir.tmpdir.join("psycopg2cffi/__init__.py").write('raise ImportError("Disabled for testing")') 292 | testdir.tmpdir.join("psycopg2").ensure(dir=1) 293 | testdir.tmpdir.join("psycopg2/__init__.py").write('raise ImportError("Disabled for testing")') 294 | testdir.tmpdir.join("psycopg").ensure(dir=1) 295 | testdir.tmpdir.join("psycopg/__init__.py").write('raise ImportError("Disabled for testing")') 296 | result = testdir.run("holdup", "-t", "0.1", proto + ":///") 297 | result.stderr.fnmatch_lines( 298 | [ 299 | f"holdup: error: argument service: Protocol {proto} unusable. Install holdup[[]pg[]].", 300 | ] 301 | ) 302 | 303 | 304 | @pytest.fixture 305 | def testdir2(testdir): 306 | os.chdir(os.path.dirname(__file__)) 307 | testdir.tmpdir.join("stderr").mksymlinkto(testdir.tmpdir.join("stdout")) 308 | return testdir 309 | 310 | 311 | @pytest.mark.skipif("not has_docker()") 312 | def test_func_pg(testdir2): 313 | result = testdir2.run("./test_pg.sh", "holdup", "pg://app:app@pg/app", "--") 314 | result.stdout.fnmatch_lines(["success !"]) 315 | assert result.ret == 0 316 | 317 | 318 | @pytest.mark.skipif("not has_docker()") 319 | def test_func_pg_tcp_service_failure(testdir2): 320 | # test that the tcp check is worse than the pg check 321 | result = testdir2.run("./test_pg.sh", "holdup", "tcp://pg:5432", "-T", "0.001", "-i", "0", "-t", "1", "-v", "--") 322 | assert result.ret == 1 323 | -------------------------------------------------------------------------------- /tests/test_pg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 -u 2 | if __name__ == "__main__": 3 | import sys 4 | import time 5 | import traceback 6 | 7 | import psycopg2 8 | 9 | start = time.time() 10 | failed = False 11 | 12 | while time.time() - start < 10: 13 | print("Connecting ...") 14 | try: 15 | conn = psycopg2.connect("postgresql://app:app@pg:5432/app") 16 | cursor = conn.cursor() 17 | cursor.execute("SELECT version()") 18 | print(cursor.fetchone()) 19 | except Exception: 20 | traceback.print_exc() 21 | failed = True 22 | else: 23 | print("Connected!") 24 | break 25 | 26 | if failed: 27 | print("Failed!") 28 | sys.exit(1) 29 | -------------------------------------------------------------------------------- /tests/test_pg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | 3 | docker-compose --ansi=never down || true 4 | docker-compose --ansi=never build --pull 5 | 6 | trap "docker-compose --ansi=never down" EXIT 7 | 8 | for try in {1..10}; do 9 | echo "Trial #$try" 10 | docker-compose --ansi=never down || true 11 | docker-compose --ansi=never up test # so networks are created without race condition 12 | (sleep 5; docker-compose --ansi=never up --detach pg) & # start pg later than usual 13 | docker-compose --ansi=never run --entrypoint "$*" test /test_pg.py || exit 1 14 | done 15 | echo "success !" 16 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv:bootstrap] 2 | deps = 3 | jinja2 4 | tox 5 | skip_install = true 6 | commands = 7 | python ci/bootstrap.py --no-env 8 | passenv = 9 | * 10 | 11 | ; a generative tox configuration, see: https://tox.wiki/en/latest/user_guide.html#generative-environments 12 | [tox] 13 | envlist = 14 | clean, 15 | check, 16 | docs, 17 | {py38,py39,py310,py311,py312,pypy38,pypy39,pypy310}-{pg2,pg3}, 18 | report 19 | ignore_basepython_conflict = true 20 | 21 | [testenv] 22 | basepython = 23 | pypy38: {env:TOXPYTHON:pypy3.8} 24 | pypy39: {env:TOXPYTHON:pypy3.9} 25 | pypy310: {env:TOXPYTHON:pypy3.10} 26 | py38: {env:TOXPYTHON:python3.8} 27 | py39: {env:TOXPYTHON:python3.9} 28 | py310: {env:TOXPYTHON:python3.10} 29 | py311: {env:TOXPYTHON:python3.11} 30 | py312: {env:TOXPYTHON:python3.12} 31 | {bootstrap,clean,check,report,docs,coveralls}: {env:TOXPYTHON:python3} 32 | setenv = 33 | PYTHONPATH={toxinidir}/tests 34 | PYTHONUNBUFFERED=yes 35 | passenv = 36 | * 37 | usedevelop = false 38 | deps = 39 | pytest 40 | pytest-cov 41 | pg3: psycopg 42 | {py38,py39,py310,py311,py312}-pg2: psycopg2-binary 43 | {pypy38,pypy39,pypy310}-pg2: psycopg2cffi 44 | commands = 45 | {posargs:pytest --cov --cov-report=term-missing --cov-report=xml -vv tests} 46 | 47 | [testenv:check] 48 | deps = 49 | docutils 50 | check-manifest 51 | pre-commit 52 | readme-renderer 53 | pygments 54 | isort 55 | skip_install = true 56 | commands = 57 | python setup.py check --strict --metadata --restructuredtext 58 | check-manifest . 59 | pre-commit run --all-files --show-diff-on-failure 60 | 61 | [testenv:docs] 62 | usedevelop = true 63 | deps = 64 | -r{toxinidir}/docs/requirements.txt 65 | commands = 66 | sphinx-build {posargs:-E} -b html docs dist/docs 67 | sphinx-build -b linkcheck docs dist/docs 68 | 69 | [testenv:report] 70 | deps = 71 | coverage 72 | skip_install = true 73 | commands = 74 | coverage report 75 | coverage html 76 | 77 | [testenv:clean] 78 | commands = 79 | python setup.py clean 80 | coverage erase 81 | skip_install = true 82 | deps = 83 | coverage 84 | --------------------------------------------------------------------------------