├── .bumpversion.cfg ├── .cookiecutterrc ├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── github-actions.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── 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 ├── installation.rst ├── readme.rst ├── reference │ ├── index.rst │ └── manhole.rst ├── requirements.txt ├── spelling_wordlist.txt └── usage.rst ├── pyproject.toml ├── pytest.ini ├── setup.py ├── src ├── manhole.embed ├── manhole.pth └── manhole │ ├── __init__.py │ └── cli.py ├── tests ├── helper.py ├── test_manhole.py ├── test_manhole_cli.py └── wsgi.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.8.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/manhole/__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-manhole/settings]' 8 | codeclimate: 'no' 9 | codecov: 'yes' 10 | command_line_interface: 'no' 11 | command_line_interface_bin_name: '-' 12 | coveralls: 'yes' 13 | distribution_name: manhole 14 | email: contact@ionelmc.ro 15 | formatter_quote_style: single 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: manhole 24 | pre_commit: 'yes' 25 | project_name: manhole 26 | project_short_description: Manhole is in-process service that will accept unix domain socket connections and present the 27 | pypi_badge: 'yes' 28 | pypi_disable_upload: 'no' 29 | release_date: '2021-04-08' 30 | repo_hosting: github.com 31 | repo_hosting_domain: github.com 32 | repo_main_branch: master 33 | repo_name: python-manhole 34 | repo_username: ionelmc 35 | scrutinizer: 'no' 36 | setup_py_uses_setuptools_scm: 'no' 37 | sphinx_docs: 'yes' 38 | sphinx_docs_hosting: https://python-manhole.readthedocs.io/ 39 | sphinx_doctest: 'no' 40 | sphinx_theme: furo 41 | test_matrix_separate_coverage: 'yes' 42 | tests_inside_package: 'no' 43 | version: 1.8.1 44 | version_manager: bump2version 45 | website: http://blog.ionelmc.ro 46 | year_from: '2012' 47 | year_to: '2024' 48 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = src 3 | 4 | [run] 5 | branch = true 6 | source = 7 | src 8 | tests 9 | parallel = true 10 | 11 | [report] 12 | show_missing = true 13 | precision = 2 14 | omit = *migrations* 15 | -------------------------------------------------------------------------------- /.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-normal-normal-cover (ubuntu)' 23 | python: '3.8' 24 | toxpython: 'python3.8' 25 | python_arch: 'x64' 26 | tox_env: 'py38-normal-normal-cover' 27 | os: 'ubuntu-latest' 28 | - name: 'py38-normal-normal-cover (macos)' 29 | python: '3.8' 30 | toxpython: 'python3.8' 31 | python_arch: 'arm64' 32 | tox_env: 'py38-normal-normal-cover' 33 | os: 'macos-latest' 34 | - name: 'py38-normal-normal-nocov (ubuntu)' 35 | python: '3.8' 36 | toxpython: 'python3.8' 37 | python_arch: 'x64' 38 | tox_env: 'py38-normal-normal-nocov' 39 | os: 'ubuntu-latest' 40 | - name: 'py38-normal-normal-nocov (macos)' 41 | python: '3.8' 42 | toxpython: 'python3.8' 43 | python_arch: 'arm64' 44 | tox_env: 'py38-normal-normal-nocov' 45 | os: 'macos-latest' 46 | - name: 'py38-normal-gevent-cover (ubuntu)' 47 | python: '3.8' 48 | toxpython: 'python3.8' 49 | python_arch: 'x64' 50 | tox_env: 'py38-normal-gevent-cover' 51 | os: 'ubuntu-latest' 52 | - name: 'py38-normal-gevent-cover (macos)' 53 | python: '3.8' 54 | toxpython: 'python3.8' 55 | python_arch: 'arm64' 56 | tox_env: 'py38-normal-gevent-cover' 57 | os: 'macos-latest' 58 | - name: 'py38-normal-gevent-nocov (ubuntu)' 59 | python: '3.8' 60 | toxpython: 'python3.8' 61 | python_arch: 'x64' 62 | tox_env: 'py38-normal-gevent-nocov' 63 | os: 'ubuntu-latest' 64 | - name: 'py38-normal-gevent-nocov (macos)' 65 | python: '3.8' 66 | toxpython: 'python3.8' 67 | python_arch: 'arm64' 68 | tox_env: 'py38-normal-gevent-nocov' 69 | os: 'macos-latest' 70 | - name: 'py38-normal-eventlet-cover (ubuntu)' 71 | python: '3.8' 72 | toxpython: 'python3.8' 73 | python_arch: 'x64' 74 | tox_env: 'py38-normal-eventlet-cover' 75 | os: 'ubuntu-latest' 76 | - name: 'py38-normal-eventlet-cover (macos)' 77 | python: '3.8' 78 | toxpython: 'python3.8' 79 | python_arch: 'arm64' 80 | tox_env: 'py38-normal-eventlet-cover' 81 | os: 'macos-latest' 82 | - name: 'py38-normal-eventlet-nocov (ubuntu)' 83 | python: '3.8' 84 | toxpython: 'python3.8' 85 | python_arch: 'x64' 86 | tox_env: 'py38-normal-eventlet-nocov' 87 | os: 'ubuntu-latest' 88 | - name: 'py38-normal-eventlet-nocov (macos)' 89 | python: '3.8' 90 | toxpython: 'python3.8' 91 | python_arch: 'arm64' 92 | tox_env: 'py38-normal-eventlet-nocov' 93 | os: 'macos-latest' 94 | - name: 'py38-signalfd-normal-cover (ubuntu)' 95 | python: '3.8' 96 | toxpython: 'python3.8' 97 | python_arch: 'x64' 98 | tox_env: 'py38-signalfd-normal-cover' 99 | os: 'ubuntu-latest' 100 | - name: 'py38-signalfd-normal-cover (macos)' 101 | python: '3.8' 102 | toxpython: 'python3.8' 103 | python_arch: 'arm64' 104 | tox_env: 'py38-signalfd-normal-cover' 105 | os: 'macos-latest' 106 | - name: 'py38-signalfd-normal-nocov (ubuntu)' 107 | python: '3.8' 108 | toxpython: 'python3.8' 109 | python_arch: 'x64' 110 | tox_env: 'py38-signalfd-normal-nocov' 111 | os: 'ubuntu-latest' 112 | - name: 'py38-signalfd-normal-nocov (macos)' 113 | python: '3.8' 114 | toxpython: 'python3.8' 115 | python_arch: 'arm64' 116 | tox_env: 'py38-signalfd-normal-nocov' 117 | os: 'macos-latest' 118 | - name: 'py38-signalfd-gevent-cover (ubuntu)' 119 | python: '3.8' 120 | toxpython: 'python3.8' 121 | python_arch: 'x64' 122 | tox_env: 'py38-signalfd-gevent-cover' 123 | os: 'ubuntu-latest' 124 | - name: 'py38-signalfd-gevent-cover (macos)' 125 | python: '3.8' 126 | toxpython: 'python3.8' 127 | python_arch: 'arm64' 128 | tox_env: 'py38-signalfd-gevent-cover' 129 | os: 'macos-latest' 130 | - name: 'py38-signalfd-gevent-nocov (ubuntu)' 131 | python: '3.8' 132 | toxpython: 'python3.8' 133 | python_arch: 'x64' 134 | tox_env: 'py38-signalfd-gevent-nocov' 135 | os: 'ubuntu-latest' 136 | - name: 'py38-signalfd-gevent-nocov (macos)' 137 | python: '3.8' 138 | toxpython: 'python3.8' 139 | python_arch: 'arm64' 140 | tox_env: 'py38-signalfd-gevent-nocov' 141 | os: 'macos-latest' 142 | - name: 'py38-signalfd-eventlet-cover (ubuntu)' 143 | python: '3.8' 144 | toxpython: 'python3.8' 145 | python_arch: 'x64' 146 | tox_env: 'py38-signalfd-eventlet-cover' 147 | os: 'ubuntu-latest' 148 | - name: 'py38-signalfd-eventlet-cover (macos)' 149 | python: '3.8' 150 | toxpython: 'python3.8' 151 | python_arch: 'arm64' 152 | tox_env: 'py38-signalfd-eventlet-cover' 153 | os: 'macos-latest' 154 | - name: 'py38-signalfd-eventlet-nocov (ubuntu)' 155 | python: '3.8' 156 | toxpython: 'python3.8' 157 | python_arch: 'x64' 158 | tox_env: 'py38-signalfd-eventlet-nocov' 159 | os: 'ubuntu-latest' 160 | - name: 'py38-signalfd-eventlet-nocov (macos)' 161 | python: '3.8' 162 | toxpython: 'python3.8' 163 | python_arch: 'arm64' 164 | tox_env: 'py38-signalfd-eventlet-nocov' 165 | os: 'macos-latest' 166 | - name: 'py39-normal-normal-cover (ubuntu)' 167 | python: '3.9' 168 | toxpython: 'python3.9' 169 | python_arch: 'x64' 170 | tox_env: 'py39-normal-normal-cover' 171 | os: 'ubuntu-latest' 172 | - name: 'py39-normal-normal-cover (macos)' 173 | python: '3.9' 174 | toxpython: 'python3.9' 175 | python_arch: 'arm64' 176 | tox_env: 'py39-normal-normal-cover' 177 | os: 'macos-latest' 178 | - name: 'py39-normal-normal-nocov (ubuntu)' 179 | python: '3.9' 180 | toxpython: 'python3.9' 181 | python_arch: 'x64' 182 | tox_env: 'py39-normal-normal-nocov' 183 | os: 'ubuntu-latest' 184 | - name: 'py39-normal-normal-nocov (macos)' 185 | python: '3.9' 186 | toxpython: 'python3.9' 187 | python_arch: 'arm64' 188 | tox_env: 'py39-normal-normal-nocov' 189 | os: 'macos-latest' 190 | - name: 'py39-normal-gevent-cover (ubuntu)' 191 | python: '3.9' 192 | toxpython: 'python3.9' 193 | python_arch: 'x64' 194 | tox_env: 'py39-normal-gevent-cover' 195 | os: 'ubuntu-latest' 196 | - name: 'py39-normal-gevent-cover (macos)' 197 | python: '3.9' 198 | toxpython: 'python3.9' 199 | python_arch: 'arm64' 200 | tox_env: 'py39-normal-gevent-cover' 201 | os: 'macos-latest' 202 | - name: 'py39-normal-gevent-nocov (ubuntu)' 203 | python: '3.9' 204 | toxpython: 'python3.9' 205 | python_arch: 'x64' 206 | tox_env: 'py39-normal-gevent-nocov' 207 | os: 'ubuntu-latest' 208 | - name: 'py39-normal-gevent-nocov (macos)' 209 | python: '3.9' 210 | toxpython: 'python3.9' 211 | python_arch: 'arm64' 212 | tox_env: 'py39-normal-gevent-nocov' 213 | os: 'macos-latest' 214 | - name: 'py39-normal-eventlet-cover (ubuntu)' 215 | python: '3.9' 216 | toxpython: 'python3.9' 217 | python_arch: 'x64' 218 | tox_env: 'py39-normal-eventlet-cover' 219 | os: 'ubuntu-latest' 220 | - name: 'py39-normal-eventlet-cover (macos)' 221 | python: '3.9' 222 | toxpython: 'python3.9' 223 | python_arch: 'arm64' 224 | tox_env: 'py39-normal-eventlet-cover' 225 | os: 'macos-latest' 226 | - name: 'py39-normal-eventlet-nocov (ubuntu)' 227 | python: '3.9' 228 | toxpython: 'python3.9' 229 | python_arch: 'x64' 230 | tox_env: 'py39-normal-eventlet-nocov' 231 | os: 'ubuntu-latest' 232 | - name: 'py39-normal-eventlet-nocov (macos)' 233 | python: '3.9' 234 | toxpython: 'python3.9' 235 | python_arch: 'arm64' 236 | tox_env: 'py39-normal-eventlet-nocov' 237 | os: 'macos-latest' 238 | - name: 'py39-signalfd-normal-cover (ubuntu)' 239 | python: '3.9' 240 | toxpython: 'python3.9' 241 | python_arch: 'x64' 242 | tox_env: 'py39-signalfd-normal-cover' 243 | os: 'ubuntu-latest' 244 | - name: 'py39-signalfd-normal-cover (macos)' 245 | python: '3.9' 246 | toxpython: 'python3.9' 247 | python_arch: 'arm64' 248 | tox_env: 'py39-signalfd-normal-cover' 249 | os: 'macos-latest' 250 | - name: 'py39-signalfd-normal-nocov (ubuntu)' 251 | python: '3.9' 252 | toxpython: 'python3.9' 253 | python_arch: 'x64' 254 | tox_env: 'py39-signalfd-normal-nocov' 255 | os: 'ubuntu-latest' 256 | - name: 'py39-signalfd-normal-nocov (macos)' 257 | python: '3.9' 258 | toxpython: 'python3.9' 259 | python_arch: 'arm64' 260 | tox_env: 'py39-signalfd-normal-nocov' 261 | os: 'macos-latest' 262 | - name: 'py39-signalfd-gevent-cover (ubuntu)' 263 | python: '3.9' 264 | toxpython: 'python3.9' 265 | python_arch: 'x64' 266 | tox_env: 'py39-signalfd-gevent-cover' 267 | os: 'ubuntu-latest' 268 | - name: 'py39-signalfd-gevent-cover (macos)' 269 | python: '3.9' 270 | toxpython: 'python3.9' 271 | python_arch: 'arm64' 272 | tox_env: 'py39-signalfd-gevent-cover' 273 | os: 'macos-latest' 274 | - name: 'py39-signalfd-gevent-nocov (ubuntu)' 275 | python: '3.9' 276 | toxpython: 'python3.9' 277 | python_arch: 'x64' 278 | tox_env: 'py39-signalfd-gevent-nocov' 279 | os: 'ubuntu-latest' 280 | - name: 'py39-signalfd-gevent-nocov (macos)' 281 | python: '3.9' 282 | toxpython: 'python3.9' 283 | python_arch: 'arm64' 284 | tox_env: 'py39-signalfd-gevent-nocov' 285 | os: 'macos-latest' 286 | - name: 'py39-signalfd-eventlet-cover (ubuntu)' 287 | python: '3.9' 288 | toxpython: 'python3.9' 289 | python_arch: 'x64' 290 | tox_env: 'py39-signalfd-eventlet-cover' 291 | os: 'ubuntu-latest' 292 | - name: 'py39-signalfd-eventlet-cover (macos)' 293 | python: '3.9' 294 | toxpython: 'python3.9' 295 | python_arch: 'arm64' 296 | tox_env: 'py39-signalfd-eventlet-cover' 297 | os: 'macos-latest' 298 | - name: 'py39-signalfd-eventlet-nocov (ubuntu)' 299 | python: '3.9' 300 | toxpython: 'python3.9' 301 | python_arch: 'x64' 302 | tox_env: 'py39-signalfd-eventlet-nocov' 303 | os: 'ubuntu-latest' 304 | - name: 'py39-signalfd-eventlet-nocov (macos)' 305 | python: '3.9' 306 | toxpython: 'python3.9' 307 | python_arch: 'arm64' 308 | tox_env: 'py39-signalfd-eventlet-nocov' 309 | os: 'macos-latest' 310 | - name: 'py310-normal-normal-cover (ubuntu)' 311 | python: '3.10' 312 | toxpython: 'python3.10' 313 | python_arch: 'x64' 314 | tox_env: 'py310-normal-normal-cover' 315 | os: 'ubuntu-latest' 316 | - name: 'py310-normal-normal-cover (macos)' 317 | python: '3.10' 318 | toxpython: 'python3.10' 319 | python_arch: 'arm64' 320 | tox_env: 'py310-normal-normal-cover' 321 | os: 'macos-latest' 322 | - name: 'py310-normal-normal-nocov (ubuntu)' 323 | python: '3.10' 324 | toxpython: 'python3.10' 325 | python_arch: 'x64' 326 | tox_env: 'py310-normal-normal-nocov' 327 | os: 'ubuntu-latest' 328 | - name: 'py310-normal-normal-nocov (macos)' 329 | python: '3.10' 330 | toxpython: 'python3.10' 331 | python_arch: 'arm64' 332 | tox_env: 'py310-normal-normal-nocov' 333 | os: 'macos-latest' 334 | - name: 'py310-normal-gevent-cover (ubuntu)' 335 | python: '3.10' 336 | toxpython: 'python3.10' 337 | python_arch: 'x64' 338 | tox_env: 'py310-normal-gevent-cover' 339 | os: 'ubuntu-latest' 340 | - name: 'py310-normal-gevent-cover (macos)' 341 | python: '3.10' 342 | toxpython: 'python3.10' 343 | python_arch: 'arm64' 344 | tox_env: 'py310-normal-gevent-cover' 345 | os: 'macos-latest' 346 | - name: 'py310-normal-gevent-nocov (ubuntu)' 347 | python: '3.10' 348 | toxpython: 'python3.10' 349 | python_arch: 'x64' 350 | tox_env: 'py310-normal-gevent-nocov' 351 | os: 'ubuntu-latest' 352 | - name: 'py310-normal-gevent-nocov (macos)' 353 | python: '3.10' 354 | toxpython: 'python3.10' 355 | python_arch: 'arm64' 356 | tox_env: 'py310-normal-gevent-nocov' 357 | os: 'macos-latest' 358 | - name: 'py310-normal-eventlet-cover (ubuntu)' 359 | python: '3.10' 360 | toxpython: 'python3.10' 361 | python_arch: 'x64' 362 | tox_env: 'py310-normal-eventlet-cover' 363 | os: 'ubuntu-latest' 364 | - name: 'py310-normal-eventlet-cover (macos)' 365 | python: '3.10' 366 | toxpython: 'python3.10' 367 | python_arch: 'arm64' 368 | tox_env: 'py310-normal-eventlet-cover' 369 | os: 'macos-latest' 370 | - name: 'py310-normal-eventlet-nocov (ubuntu)' 371 | python: '3.10' 372 | toxpython: 'python3.10' 373 | python_arch: 'x64' 374 | tox_env: 'py310-normal-eventlet-nocov' 375 | os: 'ubuntu-latest' 376 | - name: 'py310-normal-eventlet-nocov (macos)' 377 | python: '3.10' 378 | toxpython: 'python3.10' 379 | python_arch: 'arm64' 380 | tox_env: 'py310-normal-eventlet-nocov' 381 | os: 'macos-latest' 382 | - name: 'py310-signalfd-normal-cover (ubuntu)' 383 | python: '3.10' 384 | toxpython: 'python3.10' 385 | python_arch: 'x64' 386 | tox_env: 'py310-signalfd-normal-cover' 387 | os: 'ubuntu-latest' 388 | - name: 'py310-signalfd-normal-cover (macos)' 389 | python: '3.10' 390 | toxpython: 'python3.10' 391 | python_arch: 'arm64' 392 | tox_env: 'py310-signalfd-normal-cover' 393 | os: 'macos-latest' 394 | - name: 'py310-signalfd-normal-nocov (ubuntu)' 395 | python: '3.10' 396 | toxpython: 'python3.10' 397 | python_arch: 'x64' 398 | tox_env: 'py310-signalfd-normal-nocov' 399 | os: 'ubuntu-latest' 400 | - name: 'py310-signalfd-normal-nocov (macos)' 401 | python: '3.10' 402 | toxpython: 'python3.10' 403 | python_arch: 'arm64' 404 | tox_env: 'py310-signalfd-normal-nocov' 405 | os: 'macos-latest' 406 | - name: 'py310-signalfd-gevent-cover (ubuntu)' 407 | python: '3.10' 408 | toxpython: 'python3.10' 409 | python_arch: 'x64' 410 | tox_env: 'py310-signalfd-gevent-cover' 411 | os: 'ubuntu-latest' 412 | - name: 'py310-signalfd-gevent-cover (macos)' 413 | python: '3.10' 414 | toxpython: 'python3.10' 415 | python_arch: 'arm64' 416 | tox_env: 'py310-signalfd-gevent-cover' 417 | os: 'macos-latest' 418 | - name: 'py310-signalfd-gevent-nocov (ubuntu)' 419 | python: '3.10' 420 | toxpython: 'python3.10' 421 | python_arch: 'x64' 422 | tox_env: 'py310-signalfd-gevent-nocov' 423 | os: 'ubuntu-latest' 424 | - name: 'py310-signalfd-gevent-nocov (macos)' 425 | python: '3.10' 426 | toxpython: 'python3.10' 427 | python_arch: 'arm64' 428 | tox_env: 'py310-signalfd-gevent-nocov' 429 | os: 'macos-latest' 430 | - name: 'py310-signalfd-eventlet-cover (ubuntu)' 431 | python: '3.10' 432 | toxpython: 'python3.10' 433 | python_arch: 'x64' 434 | tox_env: 'py310-signalfd-eventlet-cover' 435 | os: 'ubuntu-latest' 436 | - name: 'py310-signalfd-eventlet-cover (macos)' 437 | python: '3.10' 438 | toxpython: 'python3.10' 439 | python_arch: 'arm64' 440 | tox_env: 'py310-signalfd-eventlet-cover' 441 | os: 'macos-latest' 442 | - name: 'py310-signalfd-eventlet-nocov (ubuntu)' 443 | python: '3.10' 444 | toxpython: 'python3.10' 445 | python_arch: 'x64' 446 | tox_env: 'py310-signalfd-eventlet-nocov' 447 | os: 'ubuntu-latest' 448 | - name: 'py310-signalfd-eventlet-nocov (macos)' 449 | python: '3.10' 450 | toxpython: 'python3.10' 451 | python_arch: 'arm64' 452 | tox_env: 'py310-signalfd-eventlet-nocov' 453 | os: 'macos-latest' 454 | - name: 'py311-normal-normal-cover (ubuntu)' 455 | python: '3.11' 456 | toxpython: 'python3.11' 457 | python_arch: 'x64' 458 | tox_env: 'py311-normal-normal-cover' 459 | os: 'ubuntu-latest' 460 | - name: 'py311-normal-normal-cover (macos)' 461 | python: '3.11' 462 | toxpython: 'python3.11' 463 | python_arch: 'arm64' 464 | tox_env: 'py311-normal-normal-cover' 465 | os: 'macos-latest' 466 | - name: 'py311-normal-normal-nocov (ubuntu)' 467 | python: '3.11' 468 | toxpython: 'python3.11' 469 | python_arch: 'x64' 470 | tox_env: 'py311-normal-normal-nocov' 471 | os: 'ubuntu-latest' 472 | - name: 'py311-normal-normal-nocov (macos)' 473 | python: '3.11' 474 | toxpython: 'python3.11' 475 | python_arch: 'arm64' 476 | tox_env: 'py311-normal-normal-nocov' 477 | os: 'macos-latest' 478 | - name: 'py311-normal-gevent-cover (ubuntu)' 479 | python: '3.11' 480 | toxpython: 'python3.11' 481 | python_arch: 'x64' 482 | tox_env: 'py311-normal-gevent-cover' 483 | os: 'ubuntu-latest' 484 | - name: 'py311-normal-gevent-cover (macos)' 485 | python: '3.11' 486 | toxpython: 'python3.11' 487 | python_arch: 'arm64' 488 | tox_env: 'py311-normal-gevent-cover' 489 | os: 'macos-latest' 490 | - name: 'py311-normal-gevent-nocov (ubuntu)' 491 | python: '3.11' 492 | toxpython: 'python3.11' 493 | python_arch: 'x64' 494 | tox_env: 'py311-normal-gevent-nocov' 495 | os: 'ubuntu-latest' 496 | - name: 'py311-normal-gevent-nocov (macos)' 497 | python: '3.11' 498 | toxpython: 'python3.11' 499 | python_arch: 'arm64' 500 | tox_env: 'py311-normal-gevent-nocov' 501 | os: 'macos-latest' 502 | - name: 'py311-normal-eventlet-cover (ubuntu)' 503 | python: '3.11' 504 | toxpython: 'python3.11' 505 | python_arch: 'x64' 506 | tox_env: 'py311-normal-eventlet-cover' 507 | os: 'ubuntu-latest' 508 | - name: 'py311-normal-eventlet-cover (macos)' 509 | python: '3.11' 510 | toxpython: 'python3.11' 511 | python_arch: 'arm64' 512 | tox_env: 'py311-normal-eventlet-cover' 513 | os: 'macos-latest' 514 | - name: 'py311-normal-eventlet-nocov (ubuntu)' 515 | python: '3.11' 516 | toxpython: 'python3.11' 517 | python_arch: 'x64' 518 | tox_env: 'py311-normal-eventlet-nocov' 519 | os: 'ubuntu-latest' 520 | - name: 'py311-normal-eventlet-nocov (macos)' 521 | python: '3.11' 522 | toxpython: 'python3.11' 523 | python_arch: 'arm64' 524 | tox_env: 'py311-normal-eventlet-nocov' 525 | os: 'macos-latest' 526 | - name: 'py311-signalfd-normal-cover (ubuntu)' 527 | python: '3.11' 528 | toxpython: 'python3.11' 529 | python_arch: 'x64' 530 | tox_env: 'py311-signalfd-normal-cover' 531 | os: 'ubuntu-latest' 532 | - name: 'py311-signalfd-normal-cover (macos)' 533 | python: '3.11' 534 | toxpython: 'python3.11' 535 | python_arch: 'arm64' 536 | tox_env: 'py311-signalfd-normal-cover' 537 | os: 'macos-latest' 538 | - name: 'py311-signalfd-normal-nocov (ubuntu)' 539 | python: '3.11' 540 | toxpython: 'python3.11' 541 | python_arch: 'x64' 542 | tox_env: 'py311-signalfd-normal-nocov' 543 | os: 'ubuntu-latest' 544 | - name: 'py311-signalfd-normal-nocov (macos)' 545 | python: '3.11' 546 | toxpython: 'python3.11' 547 | python_arch: 'arm64' 548 | tox_env: 'py311-signalfd-normal-nocov' 549 | os: 'macos-latest' 550 | - name: 'py311-signalfd-gevent-cover (ubuntu)' 551 | python: '3.11' 552 | toxpython: 'python3.11' 553 | python_arch: 'x64' 554 | tox_env: 'py311-signalfd-gevent-cover' 555 | os: 'ubuntu-latest' 556 | - name: 'py311-signalfd-gevent-cover (macos)' 557 | python: '3.11' 558 | toxpython: 'python3.11' 559 | python_arch: 'arm64' 560 | tox_env: 'py311-signalfd-gevent-cover' 561 | os: 'macos-latest' 562 | - name: 'py311-signalfd-gevent-nocov (ubuntu)' 563 | python: '3.11' 564 | toxpython: 'python3.11' 565 | python_arch: 'x64' 566 | tox_env: 'py311-signalfd-gevent-nocov' 567 | os: 'ubuntu-latest' 568 | - name: 'py311-signalfd-gevent-nocov (macos)' 569 | python: '3.11' 570 | toxpython: 'python3.11' 571 | python_arch: 'arm64' 572 | tox_env: 'py311-signalfd-gevent-nocov' 573 | os: 'macos-latest' 574 | - name: 'py311-signalfd-eventlet-cover (ubuntu)' 575 | python: '3.11' 576 | toxpython: 'python3.11' 577 | python_arch: 'x64' 578 | tox_env: 'py311-signalfd-eventlet-cover' 579 | os: 'ubuntu-latest' 580 | - name: 'py311-signalfd-eventlet-cover (macos)' 581 | python: '3.11' 582 | toxpython: 'python3.11' 583 | python_arch: 'arm64' 584 | tox_env: 'py311-signalfd-eventlet-cover' 585 | os: 'macos-latest' 586 | - name: 'py311-signalfd-eventlet-nocov (ubuntu)' 587 | python: '3.11' 588 | toxpython: 'python3.11' 589 | python_arch: 'x64' 590 | tox_env: 'py311-signalfd-eventlet-nocov' 591 | os: 'ubuntu-latest' 592 | - name: 'py311-signalfd-eventlet-nocov (macos)' 593 | python: '3.11' 594 | toxpython: 'python3.11' 595 | python_arch: 'arm64' 596 | tox_env: 'py311-signalfd-eventlet-nocov' 597 | os: 'macos-latest' 598 | - name: 'py312-normal-normal-cover (ubuntu)' 599 | python: '3.12' 600 | toxpython: 'python3.12' 601 | python_arch: 'x64' 602 | tox_env: 'py312-normal-normal-cover' 603 | os: 'ubuntu-latest' 604 | - name: 'py312-normal-normal-cover (macos)' 605 | python: '3.12' 606 | toxpython: 'python3.12' 607 | python_arch: 'arm64' 608 | tox_env: 'py312-normal-normal-cover' 609 | os: 'macos-latest' 610 | - name: 'py312-normal-normal-nocov (ubuntu)' 611 | python: '3.12' 612 | toxpython: 'python3.12' 613 | python_arch: 'x64' 614 | tox_env: 'py312-normal-normal-nocov' 615 | os: 'ubuntu-latest' 616 | - name: 'py312-normal-normal-nocov (macos)' 617 | python: '3.12' 618 | toxpython: 'python3.12' 619 | python_arch: 'arm64' 620 | tox_env: 'py312-normal-normal-nocov' 621 | os: 'macos-latest' 622 | - name: 'py312-normal-gevent-cover (ubuntu)' 623 | python: '3.12' 624 | toxpython: 'python3.12' 625 | python_arch: 'x64' 626 | tox_env: 'py312-normal-gevent-cover' 627 | os: 'ubuntu-latest' 628 | - name: 'py312-normal-gevent-cover (macos)' 629 | python: '3.12' 630 | toxpython: 'python3.12' 631 | python_arch: 'arm64' 632 | tox_env: 'py312-normal-gevent-cover' 633 | os: 'macos-latest' 634 | - name: 'py312-normal-gevent-nocov (ubuntu)' 635 | python: '3.12' 636 | toxpython: 'python3.12' 637 | python_arch: 'x64' 638 | tox_env: 'py312-normal-gevent-nocov' 639 | os: 'ubuntu-latest' 640 | - name: 'py312-normal-gevent-nocov (macos)' 641 | python: '3.12' 642 | toxpython: 'python3.12' 643 | python_arch: 'arm64' 644 | tox_env: 'py312-normal-gevent-nocov' 645 | os: 'macos-latest' 646 | - name: 'py312-normal-eventlet-cover (ubuntu)' 647 | python: '3.12' 648 | toxpython: 'python3.12' 649 | python_arch: 'x64' 650 | tox_env: 'py312-normal-eventlet-cover' 651 | os: 'ubuntu-latest' 652 | - name: 'py312-normal-eventlet-cover (macos)' 653 | python: '3.12' 654 | toxpython: 'python3.12' 655 | python_arch: 'arm64' 656 | tox_env: 'py312-normal-eventlet-cover' 657 | os: 'macos-latest' 658 | - name: 'py312-normal-eventlet-nocov (ubuntu)' 659 | python: '3.12' 660 | toxpython: 'python3.12' 661 | python_arch: 'x64' 662 | tox_env: 'py312-normal-eventlet-nocov' 663 | os: 'ubuntu-latest' 664 | - name: 'py312-normal-eventlet-nocov (macos)' 665 | python: '3.12' 666 | toxpython: 'python3.12' 667 | python_arch: 'arm64' 668 | tox_env: 'py312-normal-eventlet-nocov' 669 | os: 'macos-latest' 670 | - name: 'py312-signalfd-normal-cover (ubuntu)' 671 | python: '3.12' 672 | toxpython: 'python3.12' 673 | python_arch: 'x64' 674 | tox_env: 'py312-signalfd-normal-cover' 675 | os: 'ubuntu-latest' 676 | - name: 'py312-signalfd-normal-cover (macos)' 677 | python: '3.12' 678 | toxpython: 'python3.12' 679 | python_arch: 'arm64' 680 | tox_env: 'py312-signalfd-normal-cover' 681 | os: 'macos-latest' 682 | - name: 'py312-signalfd-normal-nocov (ubuntu)' 683 | python: '3.12' 684 | toxpython: 'python3.12' 685 | python_arch: 'x64' 686 | tox_env: 'py312-signalfd-normal-nocov' 687 | os: 'ubuntu-latest' 688 | - name: 'py312-signalfd-normal-nocov (macos)' 689 | python: '3.12' 690 | toxpython: 'python3.12' 691 | python_arch: 'arm64' 692 | tox_env: 'py312-signalfd-normal-nocov' 693 | os: 'macos-latest' 694 | - name: 'py312-signalfd-gevent-cover (ubuntu)' 695 | python: '3.12' 696 | toxpython: 'python3.12' 697 | python_arch: 'x64' 698 | tox_env: 'py312-signalfd-gevent-cover' 699 | os: 'ubuntu-latest' 700 | - name: 'py312-signalfd-gevent-cover (macos)' 701 | python: '3.12' 702 | toxpython: 'python3.12' 703 | python_arch: 'arm64' 704 | tox_env: 'py312-signalfd-gevent-cover' 705 | os: 'macos-latest' 706 | - name: 'py312-signalfd-gevent-nocov (ubuntu)' 707 | python: '3.12' 708 | toxpython: 'python3.12' 709 | python_arch: 'x64' 710 | tox_env: 'py312-signalfd-gevent-nocov' 711 | os: 'ubuntu-latest' 712 | - name: 'py312-signalfd-gevent-nocov (macos)' 713 | python: '3.12' 714 | toxpython: 'python3.12' 715 | python_arch: 'arm64' 716 | tox_env: 'py312-signalfd-gevent-nocov' 717 | os: 'macos-latest' 718 | - name: 'py312-signalfd-eventlet-cover (ubuntu)' 719 | python: '3.12' 720 | toxpython: 'python3.12' 721 | python_arch: 'x64' 722 | tox_env: 'py312-signalfd-eventlet-cover' 723 | os: 'ubuntu-latest' 724 | - name: 'py312-signalfd-eventlet-cover (macos)' 725 | python: '3.12' 726 | toxpython: 'python3.12' 727 | python_arch: 'arm64' 728 | tox_env: 'py312-signalfd-eventlet-cover' 729 | os: 'macos-latest' 730 | - name: 'py312-signalfd-eventlet-nocov (ubuntu)' 731 | python: '3.12' 732 | toxpython: 'python3.12' 733 | python_arch: 'x64' 734 | tox_env: 'py312-signalfd-eventlet-nocov' 735 | os: 'ubuntu-latest' 736 | - name: 'py312-signalfd-eventlet-nocov (macos)' 737 | python: '3.12' 738 | toxpython: 'python3.12' 739 | python_arch: 'arm64' 740 | tox_env: 'py312-signalfd-eventlet-nocov' 741 | os: 'macos-latest' 742 | - name: 'pypy38-normal-normal-cover (ubuntu)' 743 | python: 'pypy-3.8' 744 | toxpython: 'pypy3.8' 745 | python_arch: 'x64' 746 | tox_env: 'pypy38-normal-normal-cover' 747 | os: 'ubuntu-latest' 748 | - name: 'pypy38-normal-normal-cover (macos)' 749 | python: 'pypy-3.8' 750 | toxpython: 'pypy3.8' 751 | python_arch: 'arm64' 752 | tox_env: 'pypy38-normal-normal-cover' 753 | os: 'macos-latest' 754 | - name: 'pypy38-normal-normal-nocov (ubuntu)' 755 | python: 'pypy-3.8' 756 | toxpython: 'pypy3.8' 757 | python_arch: 'x64' 758 | tox_env: 'pypy38-normal-normal-nocov' 759 | os: 'ubuntu-latest' 760 | - name: 'pypy38-normal-normal-nocov (macos)' 761 | python: 'pypy-3.8' 762 | toxpython: 'pypy3.8' 763 | python_arch: 'arm64' 764 | tox_env: 'pypy38-normal-normal-nocov' 765 | os: 'macos-latest' 766 | - name: 'pypy38-normal-gevent-cover (ubuntu)' 767 | python: 'pypy-3.8' 768 | toxpython: 'pypy3.8' 769 | python_arch: 'x64' 770 | tox_env: 'pypy38-normal-gevent-cover' 771 | os: 'ubuntu-latest' 772 | - name: 'pypy38-normal-gevent-cover (macos)' 773 | python: 'pypy-3.8' 774 | toxpython: 'pypy3.8' 775 | python_arch: 'arm64' 776 | tox_env: 'pypy38-normal-gevent-cover' 777 | os: 'macos-latest' 778 | - name: 'pypy38-normal-gevent-nocov (ubuntu)' 779 | python: 'pypy-3.8' 780 | toxpython: 'pypy3.8' 781 | python_arch: 'x64' 782 | tox_env: 'pypy38-normal-gevent-nocov' 783 | os: 'ubuntu-latest' 784 | - name: 'pypy38-normal-gevent-nocov (macos)' 785 | python: 'pypy-3.8' 786 | toxpython: 'pypy3.8' 787 | python_arch: 'arm64' 788 | tox_env: 'pypy38-normal-gevent-nocov' 789 | os: 'macos-latest' 790 | - name: 'pypy38-normal-eventlet-cover (ubuntu)' 791 | python: 'pypy-3.8' 792 | toxpython: 'pypy3.8' 793 | python_arch: 'x64' 794 | tox_env: 'pypy38-normal-eventlet-cover' 795 | os: 'ubuntu-latest' 796 | - name: 'pypy38-normal-eventlet-cover (macos)' 797 | python: 'pypy-3.8' 798 | toxpython: 'pypy3.8' 799 | python_arch: 'arm64' 800 | tox_env: 'pypy38-normal-eventlet-cover' 801 | os: 'macos-latest' 802 | - name: 'pypy38-normal-eventlet-nocov (ubuntu)' 803 | python: 'pypy-3.8' 804 | toxpython: 'pypy3.8' 805 | python_arch: 'x64' 806 | tox_env: 'pypy38-normal-eventlet-nocov' 807 | os: 'ubuntu-latest' 808 | - name: 'pypy38-normal-eventlet-nocov (macos)' 809 | python: 'pypy-3.8' 810 | toxpython: 'pypy3.8' 811 | python_arch: 'arm64' 812 | tox_env: 'pypy38-normal-eventlet-nocov' 813 | os: 'macos-latest' 814 | - name: 'pypy38-signalfd-normal-cover (ubuntu)' 815 | python: 'pypy-3.8' 816 | toxpython: 'pypy3.8' 817 | python_arch: 'x64' 818 | tox_env: 'pypy38-signalfd-normal-cover' 819 | os: 'ubuntu-latest' 820 | - name: 'pypy38-signalfd-normal-cover (macos)' 821 | python: 'pypy-3.8' 822 | toxpython: 'pypy3.8' 823 | python_arch: 'arm64' 824 | tox_env: 'pypy38-signalfd-normal-cover' 825 | os: 'macos-latest' 826 | - name: 'pypy38-signalfd-normal-nocov (ubuntu)' 827 | python: 'pypy-3.8' 828 | toxpython: 'pypy3.8' 829 | python_arch: 'x64' 830 | tox_env: 'pypy38-signalfd-normal-nocov' 831 | os: 'ubuntu-latest' 832 | - name: 'pypy38-signalfd-normal-nocov (macos)' 833 | python: 'pypy-3.8' 834 | toxpython: 'pypy3.8' 835 | python_arch: 'arm64' 836 | tox_env: 'pypy38-signalfd-normal-nocov' 837 | os: 'macos-latest' 838 | - name: 'pypy38-signalfd-gevent-cover (ubuntu)' 839 | python: 'pypy-3.8' 840 | toxpython: 'pypy3.8' 841 | python_arch: 'x64' 842 | tox_env: 'pypy38-signalfd-gevent-cover' 843 | os: 'ubuntu-latest' 844 | - name: 'pypy38-signalfd-gevent-cover (macos)' 845 | python: 'pypy-3.8' 846 | toxpython: 'pypy3.8' 847 | python_arch: 'arm64' 848 | tox_env: 'pypy38-signalfd-gevent-cover' 849 | os: 'macos-latest' 850 | - name: 'pypy38-signalfd-gevent-nocov (ubuntu)' 851 | python: 'pypy-3.8' 852 | toxpython: 'pypy3.8' 853 | python_arch: 'x64' 854 | tox_env: 'pypy38-signalfd-gevent-nocov' 855 | os: 'ubuntu-latest' 856 | - name: 'pypy38-signalfd-gevent-nocov (macos)' 857 | python: 'pypy-3.8' 858 | toxpython: 'pypy3.8' 859 | python_arch: 'arm64' 860 | tox_env: 'pypy38-signalfd-gevent-nocov' 861 | os: 'macos-latest' 862 | - name: 'pypy38-signalfd-eventlet-cover (ubuntu)' 863 | python: 'pypy-3.8' 864 | toxpython: 'pypy3.8' 865 | python_arch: 'x64' 866 | tox_env: 'pypy38-signalfd-eventlet-cover' 867 | os: 'ubuntu-latest' 868 | - name: 'pypy38-signalfd-eventlet-cover (macos)' 869 | python: 'pypy-3.8' 870 | toxpython: 'pypy3.8' 871 | python_arch: 'arm64' 872 | tox_env: 'pypy38-signalfd-eventlet-cover' 873 | os: 'macos-latest' 874 | - name: 'pypy38-signalfd-eventlet-nocov (ubuntu)' 875 | python: 'pypy-3.8' 876 | toxpython: 'pypy3.8' 877 | python_arch: 'x64' 878 | tox_env: 'pypy38-signalfd-eventlet-nocov' 879 | os: 'ubuntu-latest' 880 | - name: 'pypy38-signalfd-eventlet-nocov (macos)' 881 | python: 'pypy-3.8' 882 | toxpython: 'pypy3.8' 883 | python_arch: 'arm64' 884 | tox_env: 'pypy38-signalfd-eventlet-nocov' 885 | os: 'macos-latest' 886 | - name: 'pypy39-normal-normal-cover (ubuntu)' 887 | python: 'pypy-3.9' 888 | toxpython: 'pypy3.9' 889 | python_arch: 'x64' 890 | tox_env: 'pypy39-normal-normal-cover' 891 | os: 'ubuntu-latest' 892 | - name: 'pypy39-normal-normal-cover (macos)' 893 | python: 'pypy-3.9' 894 | toxpython: 'pypy3.9' 895 | python_arch: 'arm64' 896 | tox_env: 'pypy39-normal-normal-cover' 897 | os: 'macos-latest' 898 | - name: 'pypy39-normal-normal-nocov (ubuntu)' 899 | python: 'pypy-3.9' 900 | toxpython: 'pypy3.9' 901 | python_arch: 'x64' 902 | tox_env: 'pypy39-normal-normal-nocov' 903 | os: 'ubuntu-latest' 904 | - name: 'pypy39-normal-normal-nocov (macos)' 905 | python: 'pypy-3.9' 906 | toxpython: 'pypy3.9' 907 | python_arch: 'arm64' 908 | tox_env: 'pypy39-normal-normal-nocov' 909 | os: 'macos-latest' 910 | - name: 'pypy39-normal-gevent-cover (ubuntu)' 911 | python: 'pypy-3.9' 912 | toxpython: 'pypy3.9' 913 | python_arch: 'x64' 914 | tox_env: 'pypy39-normal-gevent-cover' 915 | os: 'ubuntu-latest' 916 | - name: 'pypy39-normal-gevent-cover (macos)' 917 | python: 'pypy-3.9' 918 | toxpython: 'pypy3.9' 919 | python_arch: 'arm64' 920 | tox_env: 'pypy39-normal-gevent-cover' 921 | os: 'macos-latest' 922 | - name: 'pypy39-normal-gevent-nocov (ubuntu)' 923 | python: 'pypy-3.9' 924 | toxpython: 'pypy3.9' 925 | python_arch: 'x64' 926 | tox_env: 'pypy39-normal-gevent-nocov' 927 | os: 'ubuntu-latest' 928 | - name: 'pypy39-normal-gevent-nocov (macos)' 929 | python: 'pypy-3.9' 930 | toxpython: 'pypy3.9' 931 | python_arch: 'arm64' 932 | tox_env: 'pypy39-normal-gevent-nocov' 933 | os: 'macos-latest' 934 | - name: 'pypy39-normal-eventlet-cover (ubuntu)' 935 | python: 'pypy-3.9' 936 | toxpython: 'pypy3.9' 937 | python_arch: 'x64' 938 | tox_env: 'pypy39-normal-eventlet-cover' 939 | os: 'ubuntu-latest' 940 | - name: 'pypy39-normal-eventlet-cover (macos)' 941 | python: 'pypy-3.9' 942 | toxpython: 'pypy3.9' 943 | python_arch: 'arm64' 944 | tox_env: 'pypy39-normal-eventlet-cover' 945 | os: 'macos-latest' 946 | - name: 'pypy39-normal-eventlet-nocov (ubuntu)' 947 | python: 'pypy-3.9' 948 | toxpython: 'pypy3.9' 949 | python_arch: 'x64' 950 | tox_env: 'pypy39-normal-eventlet-nocov' 951 | os: 'ubuntu-latest' 952 | - name: 'pypy39-normal-eventlet-nocov (macos)' 953 | python: 'pypy-3.9' 954 | toxpython: 'pypy3.9' 955 | python_arch: 'arm64' 956 | tox_env: 'pypy39-normal-eventlet-nocov' 957 | os: 'macos-latest' 958 | - name: 'pypy39-signalfd-normal-cover (ubuntu)' 959 | python: 'pypy-3.9' 960 | toxpython: 'pypy3.9' 961 | python_arch: 'x64' 962 | tox_env: 'pypy39-signalfd-normal-cover' 963 | os: 'ubuntu-latest' 964 | - name: 'pypy39-signalfd-normal-cover (macos)' 965 | python: 'pypy-3.9' 966 | toxpython: 'pypy3.9' 967 | python_arch: 'arm64' 968 | tox_env: 'pypy39-signalfd-normal-cover' 969 | os: 'macos-latest' 970 | - name: 'pypy39-signalfd-normal-nocov (ubuntu)' 971 | python: 'pypy-3.9' 972 | toxpython: 'pypy3.9' 973 | python_arch: 'x64' 974 | tox_env: 'pypy39-signalfd-normal-nocov' 975 | os: 'ubuntu-latest' 976 | - name: 'pypy39-signalfd-normal-nocov (macos)' 977 | python: 'pypy-3.9' 978 | toxpython: 'pypy3.9' 979 | python_arch: 'arm64' 980 | tox_env: 'pypy39-signalfd-normal-nocov' 981 | os: 'macos-latest' 982 | - name: 'pypy39-signalfd-gevent-cover (ubuntu)' 983 | python: 'pypy-3.9' 984 | toxpython: 'pypy3.9' 985 | python_arch: 'x64' 986 | tox_env: 'pypy39-signalfd-gevent-cover' 987 | os: 'ubuntu-latest' 988 | - name: 'pypy39-signalfd-gevent-cover (macos)' 989 | python: 'pypy-3.9' 990 | toxpython: 'pypy3.9' 991 | python_arch: 'arm64' 992 | tox_env: 'pypy39-signalfd-gevent-cover' 993 | os: 'macos-latest' 994 | - name: 'pypy39-signalfd-gevent-nocov (ubuntu)' 995 | python: 'pypy-3.9' 996 | toxpython: 'pypy3.9' 997 | python_arch: 'x64' 998 | tox_env: 'pypy39-signalfd-gevent-nocov' 999 | os: 'ubuntu-latest' 1000 | - name: 'pypy39-signalfd-gevent-nocov (macos)' 1001 | python: 'pypy-3.9' 1002 | toxpython: 'pypy3.9' 1003 | python_arch: 'arm64' 1004 | tox_env: 'pypy39-signalfd-gevent-nocov' 1005 | os: 'macos-latest' 1006 | - name: 'pypy39-signalfd-eventlet-cover (ubuntu)' 1007 | python: 'pypy-3.9' 1008 | toxpython: 'pypy3.9' 1009 | python_arch: 'x64' 1010 | tox_env: 'pypy39-signalfd-eventlet-cover' 1011 | os: 'ubuntu-latest' 1012 | - name: 'pypy39-signalfd-eventlet-cover (macos)' 1013 | python: 'pypy-3.9' 1014 | toxpython: 'pypy3.9' 1015 | python_arch: 'arm64' 1016 | tox_env: 'pypy39-signalfd-eventlet-cover' 1017 | os: 'macos-latest' 1018 | - name: 'pypy39-signalfd-eventlet-nocov (ubuntu)' 1019 | python: 'pypy-3.9' 1020 | toxpython: 'pypy3.9' 1021 | python_arch: 'x64' 1022 | tox_env: 'pypy39-signalfd-eventlet-nocov' 1023 | os: 'ubuntu-latest' 1024 | - name: 'pypy39-signalfd-eventlet-nocov (macos)' 1025 | python: 'pypy-3.9' 1026 | toxpython: 'pypy3.9' 1027 | python_arch: 'arm64' 1028 | tox_env: 'pypy39-signalfd-eventlet-nocov' 1029 | os: 'macos-latest' 1030 | - name: 'pypy310-normal-normal-cover (ubuntu)' 1031 | python: 'pypy-3.10' 1032 | toxpython: 'pypy3.10' 1033 | python_arch: 'x64' 1034 | tox_env: 'pypy310-normal-normal-cover' 1035 | os: 'ubuntu-latest' 1036 | - name: 'pypy310-normal-normal-cover (macos)' 1037 | python: 'pypy-3.10' 1038 | toxpython: 'pypy3.10' 1039 | python_arch: 'arm64' 1040 | tox_env: 'pypy310-normal-normal-cover' 1041 | os: 'macos-latest' 1042 | - name: 'pypy310-normal-normal-nocov (ubuntu)' 1043 | python: 'pypy-3.10' 1044 | toxpython: 'pypy3.10' 1045 | python_arch: 'x64' 1046 | tox_env: 'pypy310-normal-normal-nocov' 1047 | os: 'ubuntu-latest' 1048 | - name: 'pypy310-normal-normal-nocov (macos)' 1049 | python: 'pypy-3.10' 1050 | toxpython: 'pypy3.10' 1051 | python_arch: 'arm64' 1052 | tox_env: 'pypy310-normal-normal-nocov' 1053 | os: 'macos-latest' 1054 | - name: 'pypy310-normal-gevent-cover (ubuntu)' 1055 | python: 'pypy-3.10' 1056 | toxpython: 'pypy3.10' 1057 | python_arch: 'x64' 1058 | tox_env: 'pypy310-normal-gevent-cover' 1059 | os: 'ubuntu-latest' 1060 | - name: 'pypy310-normal-gevent-cover (macos)' 1061 | python: 'pypy-3.10' 1062 | toxpython: 'pypy3.10' 1063 | python_arch: 'arm64' 1064 | tox_env: 'pypy310-normal-gevent-cover' 1065 | os: 'macos-latest' 1066 | - name: 'pypy310-normal-gevent-nocov (ubuntu)' 1067 | python: 'pypy-3.10' 1068 | toxpython: 'pypy3.10' 1069 | python_arch: 'x64' 1070 | tox_env: 'pypy310-normal-gevent-nocov' 1071 | os: 'ubuntu-latest' 1072 | - name: 'pypy310-normal-gevent-nocov (macos)' 1073 | python: 'pypy-3.10' 1074 | toxpython: 'pypy3.10' 1075 | python_arch: 'arm64' 1076 | tox_env: 'pypy310-normal-gevent-nocov' 1077 | os: 'macos-latest' 1078 | - name: 'pypy310-normal-eventlet-cover (ubuntu)' 1079 | python: 'pypy-3.10' 1080 | toxpython: 'pypy3.10' 1081 | python_arch: 'x64' 1082 | tox_env: 'pypy310-normal-eventlet-cover' 1083 | os: 'ubuntu-latest' 1084 | - name: 'pypy310-normal-eventlet-cover (macos)' 1085 | python: 'pypy-3.10' 1086 | toxpython: 'pypy3.10' 1087 | python_arch: 'arm64' 1088 | tox_env: 'pypy310-normal-eventlet-cover' 1089 | os: 'macos-latest' 1090 | - name: 'pypy310-normal-eventlet-nocov (ubuntu)' 1091 | python: 'pypy-3.10' 1092 | toxpython: 'pypy3.10' 1093 | python_arch: 'x64' 1094 | tox_env: 'pypy310-normal-eventlet-nocov' 1095 | os: 'ubuntu-latest' 1096 | - name: 'pypy310-normal-eventlet-nocov (macos)' 1097 | python: 'pypy-3.10' 1098 | toxpython: 'pypy3.10' 1099 | python_arch: 'arm64' 1100 | tox_env: 'pypy310-normal-eventlet-nocov' 1101 | os: 'macos-latest' 1102 | - name: 'pypy310-signalfd-normal-cover (ubuntu)' 1103 | python: 'pypy-3.10' 1104 | toxpython: 'pypy3.10' 1105 | python_arch: 'x64' 1106 | tox_env: 'pypy310-signalfd-normal-cover' 1107 | os: 'ubuntu-latest' 1108 | - name: 'pypy310-signalfd-normal-cover (macos)' 1109 | python: 'pypy-3.10' 1110 | toxpython: 'pypy3.10' 1111 | python_arch: 'arm64' 1112 | tox_env: 'pypy310-signalfd-normal-cover' 1113 | os: 'macos-latest' 1114 | - name: 'pypy310-signalfd-normal-nocov (ubuntu)' 1115 | python: 'pypy-3.10' 1116 | toxpython: 'pypy3.10' 1117 | python_arch: 'x64' 1118 | tox_env: 'pypy310-signalfd-normal-nocov' 1119 | os: 'ubuntu-latest' 1120 | - name: 'pypy310-signalfd-normal-nocov (macos)' 1121 | python: 'pypy-3.10' 1122 | toxpython: 'pypy3.10' 1123 | python_arch: 'arm64' 1124 | tox_env: 'pypy310-signalfd-normal-nocov' 1125 | os: 'macos-latest' 1126 | - name: 'pypy310-signalfd-gevent-cover (ubuntu)' 1127 | python: 'pypy-3.10' 1128 | toxpython: 'pypy3.10' 1129 | python_arch: 'x64' 1130 | tox_env: 'pypy310-signalfd-gevent-cover' 1131 | os: 'ubuntu-latest' 1132 | - name: 'pypy310-signalfd-gevent-cover (macos)' 1133 | python: 'pypy-3.10' 1134 | toxpython: 'pypy3.10' 1135 | python_arch: 'arm64' 1136 | tox_env: 'pypy310-signalfd-gevent-cover' 1137 | os: 'macos-latest' 1138 | - name: 'pypy310-signalfd-gevent-nocov (ubuntu)' 1139 | python: 'pypy-3.10' 1140 | toxpython: 'pypy3.10' 1141 | python_arch: 'x64' 1142 | tox_env: 'pypy310-signalfd-gevent-nocov' 1143 | os: 'ubuntu-latest' 1144 | - name: 'pypy310-signalfd-gevent-nocov (macos)' 1145 | python: 'pypy-3.10' 1146 | toxpython: 'pypy3.10' 1147 | python_arch: 'arm64' 1148 | tox_env: 'pypy310-signalfd-gevent-nocov' 1149 | os: 'macos-latest' 1150 | - name: 'pypy310-signalfd-eventlet-cover (ubuntu)' 1151 | python: 'pypy-3.10' 1152 | toxpython: 'pypy3.10' 1153 | python_arch: 'x64' 1154 | tox_env: 'pypy310-signalfd-eventlet-cover' 1155 | os: 'ubuntu-latest' 1156 | - name: 'pypy310-signalfd-eventlet-cover (macos)' 1157 | python: 'pypy-3.10' 1158 | toxpython: 'pypy3.10' 1159 | python_arch: 'arm64' 1160 | tox_env: 'pypy310-signalfd-eventlet-cover' 1161 | os: 'macos-latest' 1162 | - name: 'pypy310-signalfd-eventlet-nocov (ubuntu)' 1163 | python: 'pypy-3.10' 1164 | toxpython: 'pypy3.10' 1165 | python_arch: 'x64' 1166 | tox_env: 'pypy310-signalfd-eventlet-nocov' 1167 | os: 'ubuntu-latest' 1168 | - name: 'pypy310-signalfd-eventlet-nocov (macos)' 1169 | python: 'pypy-3.10' 1170 | toxpython: 'pypy3.10' 1171 | python_arch: 'arm64' 1172 | tox_env: 'pypy310-signalfd-eventlet-nocov' 1173 | os: 'macos-latest' 1174 | steps: 1175 | - uses: actions/checkout@v4 1176 | with: 1177 | fetch-depth: 0 1178 | - uses: actions/setup-python@v5 1179 | with: 1180 | python-version: ${{ matrix.python }} 1181 | architecture: ${{ matrix.python_arch }} 1182 | 1183 | - name: install dependencies 1184 | run: | 1185 | python -mpip install --progress-bar=off -r ci/requirements.txt 1186 | virtualenv --version 1187 | pip --version 1188 | tox --version 1189 | pip list --format=freeze 1190 | 1191 | - name: test 1192 | env: 1193 | TOXPYTHON: '${{ matrix.toxpython }}' 1194 | MANHOLE_TEST_TIMEOUT: 60 1195 | run: > 1196 | tox -e ${{ matrix.tox_env }} -v 1197 | finish: 1198 | needs: test 1199 | if: ${{ always() }} 1200 | runs-on: ubuntu-latest 1201 | steps: 1202 | - uses: coverallsapp/github-action@v2 1203 | with: 1204 | parallel-finished: true 1205 | - uses: codecov/codecov-action@v3 1206 | with: 1207 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 1208 | -------------------------------------------------------------------------------- /.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.5.0 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --exit-non-zero-on-fix, --show-fixes, --unsafe-fixes] 13 | - id: ruff-format 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v4.6.0 16 | hooks: 17 | - id: trailing-whitespace 18 | - id: end-of-file-fixer 19 | - id: debug-statements 20 | -------------------------------------------------------------------------------- /.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ș - http://blog.ionelmc.ro 6 | * Saulius Menkevičius - https://github.com/razzmatazz 7 | * Nir Soffer - https://github.com/nirs 8 | * Jesús Cea - https://github.com/jcea 9 | * "honnix" - https://github.com/honnix 10 | * Anton Ryzhov - https://github.com/anton-ryzhov 11 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 1.8.1 (2024-07-24) 6 | ------------------ 7 | 8 | * Fixed buffering issue on Python 3.11. See :issue:`66`. 9 | * Cleaned up some packaging/test problems. 10 | * Removed more leftover Python 2 code. 11 | * Fixed license metadata. See: :issue:`68`. 12 | 13 | 1.8.0 (2021-04-08) 14 | ------------------ 15 | 16 | * Simplified connection closing code. 17 | Contributed by Anton Ryzhov in :pr:`62`. 18 | * Made connection shutdown in ``manhole-cli`` more graceful. 19 | Contributed by Anton Ryzhov in :pr:`63`. 20 | 21 | 1.7.0 (2021-03-22) 22 | ------------------ 23 | 24 | * Fixed memory leak via ``sys.last_type``, ``sys.last_value``, ``sys.last_traceback``. 25 | Contributed by Anton Ryzhov in :pr:`59`. 26 | * Fixed a bunch of double-close bugs and simplified stream handler code. 27 | Contributed by Anton Ryzhov in :pr:`58`. 28 | * Loosen up ``pid`` argument parsing in ``manhole-cli`` to allow using paths with any prefix 29 | (not just ``/tmp``). 30 | 31 | 1.6.0 (2019-01-19) 32 | ------------------ 33 | 34 | * Testing improvements (changed some skips to xfail, added osx in Travis). 35 | * Fixed long standing Python 2.7 bug where ``sys.getfilesystemencoding()`` would be broken after installing a threaded 36 | manhole. See :issue:`51`. 37 | * Dropped support for Python 2.6, 3.3 and 3.4. 38 | * Fixed handling when ``socket.setdefaulttimeout()`` is used. 39 | Contributed by "honnix" in :pr:`53`. 40 | * Fixed some typos. Contributed by Jesús Cea in :pr:`43`. 41 | * Fixed handling in ``manhole-cli`` so that timeout is actually seconds and not milliseconds. 42 | Contributed by Nir Soffer in :pr:`45`. 43 | * Cleaned up useless polling options in ``manhole-cli``. 44 | Contributed by Nir Soffer in :pr:`46`. 45 | * Documented and implemented a solution for using Manhole with Eventlet. 46 | See :issue:`49`. 47 | 48 | 1.5.0 (2017-08-31) 49 | ------------------ 50 | 51 | * Added two string aliases for ``connection_handler`` option. Now you can conveniently use ``connection_handler="exec"``. 52 | * Improved ``handle_connection_exec``. It now has a clean way to exit (``exit()``) and properly closes the socket. 53 | 54 | 1.4.0 (2017-08-29) 55 | ------------------ 56 | 57 | * Added the ``connection_handler`` install option. Default value is ``manhole.handle_connection_repl``, and alternate 58 | ``manhole.handle_connection_exec`` is provided (very simple: no output redirection, no stacktrace dumping). 59 | * Dropped Python 3.2 from the test grid. It may work but it's a huge pain to support (pip/pytest don't support it anymore). 60 | * Added Python 3.5 and 3.6 in the test grid. 61 | * Fixed issues with piping to ``manhole-cli``. Now ``echo foobar | manhole-cli`` will wait 1 second for output from manhole 62 | (you can customize this with the ``--timeout`` option). 63 | * Fixed issues with newer PyPy (caused by gevent/eventlet socket unwrapping). 64 | 65 | 1.3.0 (2015-09-03) 66 | ------------------ 67 | 68 | * Allowed Manhole to be configured without any thread or activation (in case you want to manually activate). 69 | * Added an example and tests for using Manhole with uWSGi. 70 | * Fixed error handling in ``manhole-cli`` on Python 3 (exc vars don't leak anymore). 71 | * Fixed support for running in gevent/eventlet-using apps on Python 3 (now that they support Python 3). 72 | * Allowed reinstalling the manhole (in non-``strict`` mode). Previous install is undone. 73 | 74 | 1.2.0 (2015-07-06) 75 | ------------------ 76 | 77 | * Changed ``manhole-cli``: 78 | 79 | * Won't spam the terminal with errors if socket file doesn't exist. 80 | * Allowed sending any signal (new ``--signal`` argument). 81 | * Fixed some validation issues for the ``PID`` argument. 82 | 83 | 1.1.0 (2015-06-06) 84 | ------------------ 85 | 86 | * Added support for installing the manhole via the ``PYTHONMANHOLE`` environment variable. 87 | * Added a ``strict`` install option. Set it to false to avoid getting the ``AlreadyInstalled`` exception. 88 | * Added a ``manhole-cli`` script that emulates ``socat readline unix-connect:/tmp/manhole-1234``. 89 | 90 | 1.0.0 (2014-10-13) 91 | ------------------ 92 | 93 | * Added ``socket_path`` install option (contributed by `Nir Soffer`_). 94 | * Added ``reinstall_delay`` install option. 95 | * Added ``locals`` install option (contributed by `Nir Soffer`_). 96 | * Added ``redirect_stderr`` install option (contributed by `Nir Soffer`_). 97 | * Lots of internals cleanup (contributed by `Nir Soffer`_). 98 | 99 | 0.6.2 (2014-04-28) 100 | ------------------ 101 | 102 | * Fix OS X regression. 103 | 104 | 0.6.1 (2014-04-28) 105 | ------------------ 106 | 107 | * Support for OS X (contributed by `Saulius Menkevičius`_). 108 | 109 | .. _Saulius Menkevičius: https://github.com/razzmatazz 110 | .. _Nir Soffer: https://github.com/nirs 111 | -------------------------------------------------------------------------------- /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 | manhole could always use more documentation, whether as part of the 21 | official manhole 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-manhole/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-manhole` for local development: 39 | 40 | 1. Fork `python-manhole `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:YOURGITHUBNAME/python-manhole.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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2012-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 .editorconfig 10 | include .github/workflows/github-actions.yml 11 | include .pre-commit-config.yaml 12 | include .readthedocs.yml 13 | include pytest.ini 14 | include tox.ini 15 | 16 | include AUTHORS.rst 17 | include CHANGELOG.rst 18 | include CONTRIBUTING.rst 19 | include LICENSE 20 | include README.rst 21 | 22 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 23 | -------------------------------------------------------------------------------- /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| |codecov| 14 | * - package 15 | - |version| |wheel| |supported-versions| |supported-implementations| |commits-since| 16 | .. |docs| image:: https://readthedocs.org/projects/python-manhole/badge/?style=flat 17 | :target: https://readthedocs.org/projects/python-manhole/ 18 | :alt: Documentation Status 19 | 20 | .. |github-actions| image:: https://github.com/ionelmc/python-manhole/actions/workflows/github-actions.yml/badge.svg 21 | :alt: GitHub Actions Build Status 22 | :target: https://github.com/ionelmc/python-manhole/actions 23 | 24 | .. |coveralls| image:: https://coveralls.io/repos/github/ionelmc/python-manhole/badge.svg?branch=master 25 | :alt: Coverage Status 26 | :target: https://coveralls.io/github/ionelmc/python-manhole?branch=master 27 | 28 | .. |codecov| image:: https://codecov.io/gh/ionelmc/python-manhole/branch/master/graphs/badge.svg?branch=master 29 | :alt: Coverage Status 30 | :target: https://app.codecov.io/github/ionelmc/python-manhole 31 | 32 | .. |version| image:: https://img.shields.io/pypi/v/manhole.svg 33 | :alt: PyPI Package latest release 34 | :target: https://pypi.org/project/manhole 35 | 36 | .. |wheel| image:: https://img.shields.io/pypi/wheel/manhole.svg 37 | :alt: PyPI Wheel 38 | :target: https://pypi.org/project/manhole 39 | 40 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/manhole.svg 41 | :alt: Supported versions 42 | :target: https://pypi.org/project/manhole 43 | 44 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/manhole.svg 45 | :alt: Supported implementations 46 | :target: https://pypi.org/project/manhole 47 | 48 | .. |commits-since| image:: https://img.shields.io/github/commits-since/ionelmc/python-manhole/v1.8.1.svg 49 | :alt: Commits since latest release 50 | :target: https://github.com/ionelmc/python-manhole/compare/v1.8.1...master 51 | 52 | 53 | 54 | .. end-badges 55 | 56 | Manhole is in-process service that will accept unix domain socket connections and present the 57 | stacktraces for all threads and an interactive prompt. It can either work as a python daemon 58 | thread waiting for connections at all times *or* a signal handler (stopping your application and 59 | waiting for a connection). 60 | 61 | Access to the socket is restricted to the application's effective user id or root. 62 | 63 | This is just like Twisted's `manhole `__. 64 | It's simpler (no dependencies), it only runs on Unix domain sockets (in contrast to Twisted's manhole which 65 | can run on telnet or ssh) and it integrates well with various types of applications. 66 | 67 | :Documentation: http://python-manhole.readthedocs.org/en/latest/ 68 | 69 | Usage 70 | ===== 71 | 72 | Install it:: 73 | 74 | pip install manhole 75 | 76 | You can put this in your django settings, wsgi app file, some module that's always imported early etc: 77 | 78 | .. code-block:: python 79 | 80 | import manhole 81 | manhole.install() # this will start the daemon thread 82 | 83 | # and now you start your app, eg: server.serve_forever() 84 | 85 | Now in a shell you can do either of these:: 86 | 87 | netcat -U /tmp/manhole-1234 88 | socat - unix-connect:/tmp/manhole-1234 89 | socat readline unix-connect:/tmp/manhole-1234 90 | 91 | Socat with readline is best (history, editing etc). 92 | If your socat doesn't have readline try `this `_. 93 | 94 | Sample output:: 95 | 96 | $ nc -U /tmp/manhole-1234 97 | 98 | Python 2.7.3 (default, Apr 10 2013, 06:20:15) 99 | [GCC 4.6.3] on linux2 100 | Type "help", "copyright", "credits" or "license" for more information. 101 | (InteractiveConsole) 102 | >>> dir() 103 | ['__builtins__', 'dump_stacktraces', 'os', 'socket', 'sys', 'traceback'] 104 | >>> print 'foobar' 105 | foobar 106 | 107 | Alternative client 108 | ------------------ 109 | 110 | There's a new experimental ``manhole-cli`` bin since 1.1.0, that emulates ``socat``:: 111 | 112 | usage: manhole-cli [-h] [-t TIMEOUT] [-1 | -2 | -s SIGNAL] PID 113 | 114 | Connect to a manhole. 115 | 116 | positional arguments: 117 | PID A numerical process id, or a path in the form: 118 | /tmp/manhole-1234 119 | 120 | optional arguments: 121 | -h, --help show this help message and exit 122 | -t TIMEOUT, --timeout TIMEOUT 123 | Timeout to use. Default: 1 seconds. 124 | -1, -USR1 Send USR1 (10) to the process before connecting. 125 | -2, -USR2 Send USR2 (12) to the process before connecting. 126 | -s SIGNAL, --signal SIGNAL 127 | Send the given SIGNAL to the process before 128 | connecting. 129 | 130 | .. end-badges 131 | 132 | 133 | Features 134 | ======== 135 | 136 | * Uses unix domain sockets, only root or same effective user can connect. 137 | * Can run the connection in a thread or in a signal handler (see ``oneshot_on`` option). 138 | * Can start the thread listening for connections from a signal handler (see ``activate_on`` option) 139 | * Compatible with apps that fork, reinstalls the Manhole thread after fork - had to monkeypatch os.fork/os.forkpty for 140 | this. 141 | * Compatible with gevent and eventlet with some limitations - you need to either: 142 | 143 | * Use ``oneshot_on``, *or* 144 | * Disable thread monkeypatching (eg: ``gevent.monkey.patch_all(thread=False)``, ``eventlet.monkey_patch(thread=False)`` 145 | 146 | Note: on eventlet `you might `_ need to setup the hub first to prevent 147 | circular import problems: 148 | 149 | .. sourcecode:: python 150 | 151 | import eventlet 152 | eventlet.hubs.get_hub() # do this first 153 | eventlet.monkey_patch(thread=False) 154 | 155 | * The thread is compatible with apps that use signalfd (will mask all signals for the Manhole threads). 156 | 157 | Options 158 | ------- 159 | 160 | .. code-block:: python 161 | 162 | manhole.install( 163 | verbose=True, 164 | verbose_destination=2, 165 | patch_fork=True, 166 | activate_on=None, 167 | oneshot_on=None, 168 | sigmask=manhole.ALL_SIGNALS, 169 | socket_path=None, 170 | reinstall_delay=0.5, 171 | locals=None, 172 | strict=True, 173 | ) 174 | 175 | * ``verbose`` - Set it to ``False`` to squelch the logging. 176 | * ``verbose_destination`` - Destination for verbose messages. Set it to a file descriptor or handle. Default is 177 | unbuffered stderr (stderr ``2`` file descriptor). 178 | * ``patch_fork`` - Set it to ``False`` if you don't want your ``os.fork`` and ``os.forkpy`` monkeypatched 179 | * ``activate_on`` - Set to ``"USR1"``, ``"USR2"`` or some other signal name, or a number if you want the Manhole thread 180 | to start when this signal is sent. This is desirable in case you don't want the thread active all the time. 181 | * ``thread`` - Set to ``True`` to start the always-on ManholeThread. Default: ``True``. 182 | Automatically switched to ``False`` if ``oneshot_on`` or ``activate_on`` are used. 183 | * ``oneshot_on`` - Set to ``"USR1"``, ``"USR2"`` or some other signal name, or a number if you want the Manhole to 184 | listen for connection in the signal handler. This is desireable in case you don't want threads at all. 185 | * ``sigmask`` - Will set the signal mask to the given list (using ``signalfd.sigprocmask``). No action is done if 186 | ``signalfd`` is not importable. **NOTE**: This is done so that the Manhole thread doesn't *steal* any signals; 187 | Normally that is fine because Python will force all the signal handling to be run in the main thread but signalfd 188 | doesn't. 189 | * ``socket_path`` - Use a specific path for the unix domain socket (instead of ``/tmp/manhole-``). This disables 190 | ``patch_fork`` as children cannot reuse the same path. 191 | * ``reinstall_delay`` - Delay the unix domain socket creation *reinstall_delay* seconds. This alleviates 192 | cleanup failures when using fork+exec patterns. 193 | * ``locals`` - Names to add to manhole interactive shell locals. 194 | * ``daemon_connection`` - The connection thread is daemonic (dies on app exit). Default: ``False``. 195 | * ``redirect_stderr`` - Redirect output from stderr to manhole console. Default: ``True``. 196 | * ``strict`` - If ``True`` then ``AlreadyInstalled`` will be raised when attempting to install manhole twice. 197 | Default: ``True``. 198 | 199 | Environment variable installation 200 | --------------------------------- 201 | 202 | Manhole can be installed via the ``PYTHONMANHOLE`` environment variable. 203 | 204 | This:: 205 | 206 | PYTHONMANHOLE='' python yourapp.py 207 | 208 | Is equivalent to having this in ``yourapp.py``:: 209 | 210 | import manhole 211 | manhole.install() 212 | 213 | Any extra text in the environment variable is passed to ``manhole.install()``. Example:: 214 | 215 | PYTHONMANHOLE='oneshot_on="USR2"' python yourapp.py 216 | 217 | What happens when you actually connect to the socket 218 | ---------------------------------------------------- 219 | 220 | 1. Credentials are checked (if it's same user or root) 221 | 2. ``sys.__std*__``/``sys.std*`` are redirected to the UDS 222 | 3. Stacktraces for each thread are written to the UDS 223 | 4. REPL is started so you can fiddle with the process 224 | 225 | Known issues 226 | ============ 227 | 228 | * Using threads and file handle (not raw file descriptor) ``verbose_destination`` can cause deadlocks. See bug reports: 229 | `PyPy `_ and `Python 3.4 `_. 230 | 231 | SIGTERM and socket cleanup 232 | -------------------------- 233 | 234 | By default Python doesn't call the ``atexit`` callbacks with the default SIGTERM handling. This makes manhole leave 235 | stray socket files around. If this is undesirable you should install a custom SIGTERM handler so ``atexit`` is 236 | properly invoked. 237 | 238 | Example: 239 | 240 | .. code-block:: python 241 | 242 | import signal 243 | import sys 244 | 245 | def handle_sigterm(signo, frame): 246 | sys.exit(128 + signo) # this will raise SystemExit and cause atexit to be called 247 | 248 | signal.signal(signal.SIGTERM, handle_sigterm) 249 | 250 | Using Manhole with uWSGI 251 | ------------------------ 252 | 253 | Because uWSGI overrides signal handling Manhole is a bit more tricky to setup. One way is to use "uWSGI signals" (not 254 | the POSIX signals) and have the workers check a file for the pid you want to open the Manhole in. 255 | 256 | Stick something this in your WSGI application file: 257 | 258 | .. sourcecode:: python 259 | 260 | from __future__ import print_function 261 | import sys 262 | import os 263 | import manhole 264 | 265 | stack_dump_file = '/tmp/manhole-pid' 266 | uwsgi_signal_number = 17 267 | 268 | try: 269 | import uwsgi 270 | 271 | if not os.path.exists(stack_dump_file): 272 | open(stack_dump_file, 'w') 273 | 274 | def open_manhole(dummy_signum): 275 | with open(stack_dump_file, 'r') as fh: 276 | pid = fh.read().strip() 277 | if pid == str(os.getpid()): 278 | inst = manhole.install(strict=False, thread=False) 279 | inst.handle_oneshot(dummy_signum, dummy_signum) 280 | 281 | uwsgi.register_signal(uwsgi_signal_number, 'workers', open_manhole) 282 | uwsgi.add_file_monitor(uwsgi_signal_number, stack_dump_file) 283 | 284 | print("Listening for stack mahole requests via %r" % (stack_dump_file,), file=sys.stderr) 285 | except ImportError: 286 | print("Not running under uwsgi; unable to configure manhole trigger", file=sys.stderr) 287 | except IOError: 288 | print("IOError creating manhole trigger %r" % (stack_dump_file,), file=sys.stderr) 289 | 290 | 291 | # somewhere bellow you'd have something like 292 | from django.core.wsgi import get_wsgi_application 293 | application = get_wsgi_application() 294 | # or 295 | def application(environ, start_response): 296 | start_response('200 OK', [('Content-Type', 'text/plain'), ('Content-Length', '2')]) 297 | yield b'OK' 298 | 299 | To open the Manhole just run `echo 1234 > /tmp/manhole-pid` and then `manhole-cli 1234`. 300 | 301 | Requirements 302 | ============ 303 | 304 | :OS: Linux, OS X 305 | :Runtime: Python 2.7, 3.4, 3.5, 3.6 or PyPy 306 | 307 | Similar projects 308 | ================ 309 | 310 | * Twisted's `manhole `__ - it has colors and 311 | server-side history. 312 | * `wsgi-shell `_ - spawns a thread. 313 | * `pyrasite `_ - uses gdb to inject code. 314 | * `pydbattach `_ - uses gdb to inject code. 315 | * `pystuck `_ - very similar, uses `rpyc `_ for 316 | communication. 317 | * `pyringe `_ - uses gdb to inject code, more reliable, but relies on `dbg` python 318 | builds unfortunatelly. 319 | * `pdb-clone `_ - uses gdb to inject code, with a `different strategy 320 | `_. 321 | -------------------------------------------------------------------------------- /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 }}' 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 | 54 | - name: install dependencies 55 | run: | 56 | python -mpip install --progress-bar=off -r ci/requirements.txt 57 | virtualenv --version 58 | pip --version 59 | tox --version 60 | pip list --format=freeze 61 | 62 | - name: test 63 | env: 64 | TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' 65 | MANHOLE_TEST_TIMEOUT: 60 66 | run: > 67 | tox -e {{ '${{ matrix.tox_env }}' }} -v 68 | finish: 69 | needs: test 70 | if: {{ '${{ always() }}' }} 71 | runs-on: ubuntu-latest 72 | steps: 73 | - uses: coverallsapp/github-action@v2 74 | with: 75 | parallel-finished: true 76 | - uses: codecov/codecov-action@v3 77 | with: 78 | CODECOV_TOKEN: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} 79 | {{ '' }} 80 | -------------------------------------------------------------------------------- /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 = 'manhole' 15 | year = '2012-2024' 16 | author = 'Ionel Cristian Mărieș' 17 | copyright = f'{year}, {author}' 18 | version = release = '1.8.1' 19 | 20 | pygments_style = 'trac' 21 | templates_path = ['.'] 22 | extlinks = { 23 | 'issue': ('https://github.com/ionelmc/python-manhole/issues/%s', '#%s'), 24 | 'pr': ('https://github.com/ionelmc/python-manhole/pull/%s', 'PR #%s'), 25 | } 26 | 27 | html_theme = 'furo' 28 | html_theme_options = { 29 | 'githuburl': 'https://github.com/ionelmc/python-manhole/', 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 | installation 10 | usage 11 | reference/index 12 | contributing 13 | authors 14 | changelog 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | pip install manhole 8 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. toctree:: 5 | :glob: 6 | 7 | manhole* 8 | -------------------------------------------------------------------------------- /docs/reference/manhole.rst: -------------------------------------------------------------------------------- 1 | manhole 2 | ======= 3 | 4 | .. testsetup:: 5 | 6 | from manhole import * 7 | 8 | .. automodule:: manhole 9 | :members: 10 | :undoc-members: 11 | :special-members: __init__, __len__ 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use manhole in a project:: 6 | 7 | import manhole 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=64", 4 | ] 5 | 6 | [tool.ruff.per-file-ignores] 7 | "ci/*" = ["S"] 8 | 9 | [tool.ruff] 10 | extend-exclude = ["static", "ci/templates"] 11 | line-length = 140 12 | src = ["src", "tests"] 13 | target-version = "py38" 14 | 15 | [tool.ruff.lint.per-file-ignores] 16 | "ci/*" = ["S"] 17 | 18 | [tool.ruff.lint] 19 | ignore = [ 20 | "RUF001", # ruff-specific rules ambiguous-unicode-character-string 21 | "S101", # flake8-bandit assert 22 | "S308", # flake8-bandit suspicious-mark-safe-usage 23 | "E501", # pycodestyle line-too-long 24 | "B008", 25 | "S108", 26 | "S110", 27 | "S307", 28 | "S603", 29 | "S606", 30 | "S607", 31 | ] 32 | select = [ 33 | "B", # flake8-bugbear 34 | "C4", # flake8-comprehensions 35 | "DTZ", # flake8-datetimez 36 | "E", # pycodestyle errors 37 | "EXE", # flake8-executable 38 | "F", # pyflakes 39 | "I", # isort 40 | "INT", # flake8-gettext 41 | "PIE", # flake8-pie 42 | "PLC", # pylint convention 43 | "PLE", # pylint errors 44 | "PT", # flake8-pytest-style 45 | # "PTH", # flake8-use-pathlib 46 | "RSE", # flake8-raise 47 | "RUF", # ruff-specific rules 48 | "S", # flake8-bandit 49 | "UP", # pyupgrade 50 | "W", # pycodestyle warnings 51 | ] 52 | 53 | [tool.ruff.lint.flake8-pytest-style] 54 | fixture-parentheses = false 55 | mark-parentheses = false 56 | 57 | [tool.ruff.lint.isort] 58 | forced-separate = ["conftest"] 59 | force-single-line = true 60 | 61 | [tool.ruff.format] 62 | quote-style = "single" 63 | -------------------------------------------------------------------------------- /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 | .git 8 | .tox 9 | .env 10 | dist 11 | build 12 | migrations 13 | 14 | python_files = 15 | test_*.py 16 | *_test.py 17 | tests.py 18 | addopts = 19 | -ra 20 | --strict-markers 21 | --ignore=docs/conf.py 22 | --ignore=setup.py 23 | --ignore=ci 24 | --ignore=.eggs 25 | --doctest-modules 26 | --doctest-glob=\*.rst 27 | --tb=short 28 | testpaths = 29 | tests 30 | # If you want to switch back to tests outside package just remove --pyargs 31 | # and edit testpaths to have "tests/" instead of "manhole". 32 | 33 | # Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors 34 | filterwarnings = 35 | error 36 | # You can add exclusions, some examples: 37 | ignore:unclosed:ResourceWarning:: 38 | # ignore:The {{% if::: 39 | # ignore:Coverage disabled via --no-cov switch! 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | from distutils.command.build import build 4 | from itertools import chain 5 | from os import fspath 6 | from pathlib import Path 7 | 8 | from setuptools import Command 9 | from setuptools import find_packages 10 | from setuptools import setup 11 | from setuptools.command.develop import develop 12 | from setuptools.command.easy_install import easy_install 13 | from setuptools.command.editable_wheel import editable_wheel 14 | from setuptools.command.install_lib import install_lib 15 | 16 | pth_file = Path(__file__).parent.joinpath('src', 'manhole.pth') 17 | 18 | 19 | class BuildWithPTH(build): 20 | def run(self): 21 | super().run() 22 | self.copy_file(fspath(pth_file), fspath(Path(self.build_lib, pth_file.name))) 23 | 24 | 25 | class PTHWheelPiggyback: 26 | def __init__(self, strategy): 27 | self.strategy = strategy 28 | 29 | def __enter__(self): 30 | self.strategy.__enter__() 31 | 32 | def __exit__(self, exc_type, exc_val, exc_tb): 33 | self.strategy.__exit__(exc_type, exc_val, exc_tb) 34 | 35 | def __call__(self, wheel, files, mapping): 36 | self.strategy(wheel, files, mapping) 37 | wheel.writestr(fspath(pth_file.name), pth_file.read_bytes()) 38 | 39 | 40 | class EditableWheelWithPTH(editable_wheel): 41 | def _select_strategy(self, dist_name, tag, lib): 42 | return PTHWheelPiggyback(super()._select_strategy(dist_name, tag, lib)) 43 | 44 | 45 | class EasyInstallWithPTH(easy_install): 46 | def run(self, *args, **kwargs): 47 | super().run(*args, **kwargs) 48 | self.copy_file(fspath(pth_file), str(Path(self.install_dir, pth_file.name))) 49 | 50 | 51 | class InstallLibWithPTH(install_lib): 52 | def run(self): 53 | super().run() 54 | dest = str(Path(self.install_dir, pth_file.name)) 55 | self.copy_file(fspath(pth_file), dest) 56 | self.outputs = [dest] 57 | 58 | def get_outputs(self): 59 | return chain(install_lib.get_outputs(self), self.outputs) 60 | 61 | 62 | class DevelopWithPTH(develop): 63 | def run(self): 64 | super().run() 65 | self.copy_file(fspath(pth_file), str(Path(self.install_dir, pth_file.name))) 66 | 67 | 68 | class GeneratePTH(Command): 69 | user_options = [] # noqa: RUF012 70 | 71 | def initialize_options(self): 72 | pass 73 | 74 | def finalize_options(self): 75 | pass 76 | 77 | def run(self): 78 | with pth_file.open('w') as fh: 79 | with pth_file.with_suffix('.embed').open() as sh: 80 | fh.write(f"import os, sys;exec({sh.read().replace(' ', ' ')!r})") 81 | 82 | 83 | def read(*names, **kwargs): 84 | with Path(__file__).parent.joinpath(*names).open(encoding=kwargs.get('encoding', 'utf8')) as fh: 85 | return fh.read() 86 | 87 | 88 | setup( 89 | name='manhole', 90 | version='1.8.1', 91 | license='BSD-2-Clause', 92 | description='Manhole is in-process service that will accept unix domain socket connections and present the' 93 | 'stacktraces for all threads and an interactive prompt.', 94 | long_description='{}\n{}'.format( 95 | re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.rst')), 96 | re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst')), 97 | ), 98 | author='Ionel Cristian Mărieș', 99 | author_email='contact@ionelmc.ro', 100 | url='https://github.com/ionelmc/python-manhole', 101 | packages=find_packages('src'), 102 | package_dir={'': 'src'}, 103 | py_modules=[path.stem for path in Path('src').glob('*.py')], 104 | include_package_data=True, 105 | zip_safe=False, 106 | classifiers=[ 107 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 108 | 'Development Status :: 5 - Production/Stable', 109 | 'Intended Audience :: Developers', 110 | 'License :: OSI Approved :: BSD License', 111 | 'Operating System :: Unix', 112 | 'Operating System :: POSIX', 113 | 'Programming Language :: Python', 114 | 'Topic :: Software Development :: Debuggers', 115 | 'Topic :: Utilities', 116 | 'Topic :: System :: Monitoring', 117 | 'Topic :: System :: Networking', 118 | 'Programming Language :: Python :: 3', 119 | 'Programming Language :: Python :: 3 :: Only', 120 | 'Programming Language :: Python :: 3.8', 121 | 'Programming Language :: Python :: 3.9', 122 | 'Programming Language :: Python :: 3.10', 123 | 'Programming Language :: Python :: 3.11', 124 | 'Programming Language :: Python :: 3.12', 125 | 'Programming Language :: Python :: Implementation :: CPython', 126 | 'Programming Language :: Python :: Implementation :: PyPy', 127 | # uncomment if you test on these interpreters: 128 | # "Programming Language :: Python :: Implementation :: IronPython", 129 | # "Programming Language :: Python :: Implementation :: Jython", 130 | # "Programming Language :: Python :: Implementation :: Stackless", 131 | 'Topic :: Utilities', 132 | ], 133 | project_urls={ 134 | 'Documentation': 'https://python-manhole.readthedocs.io/', 135 | 'Changelog': 'https://python-manhole.readthedocs.io/en/latest/changelog.html', 136 | 'Issue Tracker': 'https://github.com/ionelmc/python-manhole/issues', 137 | }, 138 | entry_points={ 139 | 'console_scripts': [ 140 | 'manhole-cli = manhole.cli:main', 141 | ] 142 | }, 143 | keywords=['debugging', 'manhole', 'thread', 'socket', 'unix domain socket'], 144 | python_requires='>=3.8', 145 | install_requires=[ 146 | # eg: "aspectlib==1.1.1", "six>=1.7", 147 | ], 148 | extras_require={ 149 | # eg: 150 | # "rst": ["docutils>=0.11"], 151 | # ":python_version=="2.6"": ["argparse"], 152 | }, 153 | cmdclass={ 154 | 'build': BuildWithPTH, 155 | 'easy_install': EasyInstallWithPTH, 156 | 'install_lib': InstallLibWithPTH, 157 | 'develop': DevelopWithPTH, 158 | 'editable_wheel': EditableWheelWithPTH, 159 | 'genpth': GeneratePTH, 160 | }, 161 | ) 162 | -------------------------------------------------------------------------------- /src/manhole.embed: -------------------------------------------------------------------------------- 1 | if "PYTHONMANHOLE" in os.environ: 2 | try: 3 | from manhole import install 4 | eval("install({0[PYTHONMANHOLE]})".format(os.environ)) 5 | except Exception as exc: 6 | sys.stderr.write("Failed to manhole.install({[PYTHONMANHOLE]}): {!r}\n".format(os.environ, exc)) 7 | -------------------------------------------------------------------------------- /src/manhole.pth: -------------------------------------------------------------------------------- 1 | import os, sys;exec('if "PYTHONMANHOLE" in os.environ:\n try:\n from manhole import install\n eval("install({0[PYTHONMANHOLE]})".format(os.environ))\n except Exception as exc:\n sys.stderr.write("Failed to manhole.install({[PYTHONMANHOLE]}): {!r}\\n".format(os.environ, exc))\n') 2 | -------------------------------------------------------------------------------- /src/manhole/__init__.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import code 3 | import errno 4 | import os 5 | import signal 6 | import socket 7 | import struct 8 | import sys 9 | import traceback 10 | from contextlib import closing 11 | 12 | __version__ = '1.8.1' 13 | 14 | from io import TextIOWrapper 15 | 16 | try: 17 | import signalfd 18 | except ImportError: 19 | signalfd = None 20 | try: 21 | string = basestring 22 | except NameError: # python 3 23 | string = str 24 | try: 25 | InterruptedError = InterruptedError 26 | except NameError: # python <= 3.2 27 | InterruptedError = OSError 28 | try: 29 | BrokenPipeError = BrokenPipeError 30 | except NameError: # old python 31 | 32 | class BrokenPipeError(Exception): 33 | pass 34 | 35 | 36 | if hasattr(sys, 'setswitchinterval'): 37 | setinterval = sys.setswitchinterval 38 | getinterval = sys.getswitchinterval 39 | else: 40 | setinterval = sys.setcheckinterval 41 | getinterval = sys.getcheckinterval 42 | 43 | try: 44 | from eventlet.patcher import original as _original 45 | 46 | def _get_original(mod, name): 47 | return getattr(_original(mod), name) 48 | 49 | except ImportError: 50 | try: 51 | from gevent.monkey import get_original as _get_original 52 | except ImportError: 53 | 54 | def _get_original(mod, name): 55 | return getattr(__import__(mod), name) 56 | 57 | 58 | _ORIGINAL_SOCKET = _get_original('socket', 'socket') 59 | try: 60 | _ORIGINAL_ALLOCATE_LOCK = _get_original('thread', 'allocate_lock') 61 | except ImportError: # python 3 62 | _ORIGINAL_ALLOCATE_LOCK = _get_original('_thread', 'allocate_lock') 63 | _ORIGINAL_THREAD = _get_original('threading', 'Thread') 64 | _ORIGINAL_EVENT = _get_original('threading', 'Event') 65 | _ORIGINAL__ACTIVE = _get_original('threading', '_active') 66 | _ORIGINAL_SLEEP = _get_original('time', 'sleep') 67 | 68 | try: 69 | import ctypes 70 | import ctypes.util 71 | 72 | libpthread_path = ctypes.util.find_library('pthread') 73 | if not libpthread_path: 74 | raise ImportError 75 | libpthread = ctypes.CDLL(libpthread_path) 76 | if not hasattr(libpthread, 'pthread_setname_np'): 77 | raise ImportError 78 | _pthread_setname_np = libpthread.pthread_setname_np 79 | _pthread_setname_np.argtypes = [ctypes.c_void_p, ctypes.c_char_p] 80 | _pthread_setname_np.restype = ctypes.c_int 81 | 82 | def pthread_setname_np(ident, name): 83 | _pthread_setname_np(ident, name[:15]) 84 | 85 | except ImportError: 86 | 87 | def pthread_setname_np(ident, name): 88 | pass 89 | 90 | 91 | if sys.platform == 'darwin' or sys.platform.startswith('freebsd'): 92 | _PEERCRED_LEVEL = getattr(socket, 'SOL_LOCAL', 0) 93 | _PEERCRED_OPTION = getattr(socket, 'LOCAL_PEERCRED', 1) 94 | else: 95 | _PEERCRED_LEVEL = socket.SOL_SOCKET 96 | # TODO: Is this missing on some platforms? 97 | _PEERCRED_OPTION = getattr(socket, 'SO_PEERCRED', 17) 98 | 99 | _ALL_SIGNALS = tuple(getattr(signal, sig) for sig in dir(signal) if sig.startswith('SIG') and '_' not in sig) 100 | 101 | # These (_LOG and _MANHOLE) will hold instances after install 102 | _MANHOLE = None 103 | _LOCK = _ORIGINAL_ALLOCATE_LOCK() 104 | 105 | 106 | def force_original_socket(sock): 107 | with closing(sock): 108 | if hasattr(sock, 'detach'): 109 | return _ORIGINAL_SOCKET(sock.family, sock.type, sock.proto, sock.detach()) 110 | else: 111 | assert hasattr(_ORIGINAL_SOCKET, '_sock') 112 | return _ORIGINAL_SOCKET(_sock=sock._sock) 113 | 114 | 115 | def get_peercred(sock): 116 | """Gets the (pid, uid, gid) for the client on the given *connected* socket.""" 117 | buf = sock.getsockopt(_PEERCRED_LEVEL, _PEERCRED_OPTION, struct.calcsize('3i')) 118 | return struct.unpack('3i', buf) 119 | 120 | 121 | class AlreadyInstalled(Exception): 122 | pass 123 | 124 | 125 | class NotInstalled(Exception): 126 | pass 127 | 128 | 129 | class ConfigurationConflict(Exception): 130 | pass 131 | 132 | 133 | class SuspiciousClient(Exception): 134 | pass 135 | 136 | 137 | class ManholeThread(_ORIGINAL_THREAD): 138 | """ 139 | Thread that runs the infamous "Manhole". This thread is a `daemon` thread - it will exit if the main thread 140 | exits. 141 | 142 | On connect, a different, non-daemon thread will be started - so that the process won't exit while there's a 143 | connection to the manhole. 144 | 145 | Args: 146 | sigmask (list of signal numbers): Signals to block in this thread. 147 | start_timeout (float): Seconds to wait for the thread to start. Emits a message if the thread is not running 148 | when calling ``start()``. 149 | bind_delay (float): Seconds to delay socket binding. Default: `no delay`. 150 | daemon_connection (bool): The connection thread is daemonic (dies on app exit). Default: ``False``. 151 | """ 152 | 153 | def __init__(self, get_socket, sigmask, start_timeout, connection_handler, bind_delay=None, daemon_connection=False): 154 | super().__init__() 155 | self.daemon = True 156 | self.daemon_connection = daemon_connection 157 | self.name = 'Manhole' 158 | self.psname = b'Manhole' 159 | self.sigmask = sigmask 160 | self.serious = _ORIGINAL_EVENT() 161 | # time to wait for the manhole to get serious (to have a complete start) 162 | # see: http://emptysqua.re/blog/dawn-of-the-thread/ 163 | self.start_timeout = start_timeout 164 | self.bind_delay = bind_delay 165 | self.connection_handler = connection_handler 166 | self.get_socket = get_socket 167 | self.should_run = False 168 | 169 | def stop(self): 170 | self.should_run = False 171 | 172 | def clone(self, **kwargs): 173 | """ 174 | Make a fresh thread with the same options. This is usually used on dead threads. 175 | """ 176 | return ManholeThread( 177 | self.get_socket, 178 | self.sigmask, 179 | self.start_timeout, 180 | connection_handler=self.connection_handler, 181 | daemon_connection=self.daemon_connection, 182 | **kwargs, 183 | ) 184 | 185 | def start(self): 186 | self.should_run = True 187 | super().start() 188 | if not self.serious.wait(self.start_timeout): 189 | _LOG(f"WARNING: Waited {self.start_timeout} seconds but Manhole thread didn't start yet :(") 190 | 191 | def run(self): 192 | """ 193 | Runs the manhole loop. Only accepts one connection at a time because: 194 | 195 | * This thread is a daemon thread (exits when main thread exists). 196 | * The connection need exclusive access to stdin, stderr and stdout so it can redirect inputs and outputs. 197 | """ 198 | self.serious.set() 199 | if signalfd and self.sigmask: 200 | signalfd.sigprocmask(signalfd.SIG_BLOCK, self.sigmask) 201 | pthread_setname_np(self.ident, self.psname) 202 | 203 | if self.bind_delay: 204 | _LOG(f'Delaying UDS binding {self.bind_delay} seconds ...') 205 | _ORIGINAL_SLEEP(self.bind_delay) 206 | 207 | sock = self.get_socket() 208 | while self.should_run: 209 | _LOG(f'Waiting for new connection (in pid:{os.getpid()}) ...') 210 | try: 211 | client = ManholeConnectionThread(sock.accept()[0], self.connection_handler, self.daemon_connection) 212 | client.start() 213 | client.join() 214 | except socket.timeout: 215 | continue 216 | except (OSError, InterruptedError) as e: 217 | if e.errno != errno.EINTR: 218 | raise 219 | continue 220 | finally: 221 | client = None 222 | 223 | 224 | class ManholeConnectionThread(_ORIGINAL_THREAD): 225 | """ 226 | Manhole thread that handles the connection. This thread is a normal thread (non-daemon) - it won't exit if the 227 | main thread exits. 228 | """ 229 | 230 | def __init__(self, client, connection_handler, daemon=False): 231 | super().__init__() 232 | self.daemon = daemon 233 | self.client = force_original_socket(client) 234 | self.connection_handler = connection_handler 235 | self.name = 'ManholeConnectionThread' 236 | self.psname = b'ManholeConnectionThread' 237 | 238 | def run(self): 239 | _LOG('Started ManholeConnectionThread thread. Checking credentials ...') 240 | pthread_setname_np(self.ident, b'Manhole -------') 241 | pid, _, _ = check_credentials(self.client) 242 | pthread_setname_np(self.ident, b'Manhole < PID:%d' % pid) 243 | try: 244 | self.connection_handler(self.client) 245 | except BaseException as exc: 246 | _LOG(f'ManholeConnectionThread failure: {exc!r}') 247 | 248 | 249 | def check_credentials(client): 250 | """ 251 | Checks credentials for given socket. 252 | """ 253 | pid, uid, gid = get_peercred(client) 254 | 255 | euid = os.geteuid() 256 | client_name = f'PID:{pid} UID:{uid} GID:{gid}' 257 | if uid not in (0, euid): 258 | raise SuspiciousClient(f"Can't accept client with {client_name}. It doesn't match the current EUID:{euid} or ROOT.") 259 | 260 | _LOG(f'Accepted connection on fd:{client.fileno()} from {client_name}') 261 | return pid, uid, gid 262 | 263 | 264 | def handle_connection_exec(client): 265 | """ 266 | Alternate connection handler. No output redirection. 267 | """ 268 | 269 | class ExitExecLoop(Exception): 270 | pass 271 | 272 | def exit(): 273 | raise ExitExecLoop 274 | 275 | client.settimeout(None) 276 | fh = client.makefile() 277 | 278 | with closing(client): 279 | with closing(fh): 280 | try: 281 | payload = fh.readline() 282 | while payload: 283 | _LOG(f'Running: {payload!r}.') 284 | eval(compile(payload, '', 'exec'), {'exit': exit}, _MANHOLE.locals) 285 | payload = fh.readline() 286 | except ExitExecLoop: 287 | _LOG('Exiting exec loop.') 288 | 289 | 290 | def handle_connection_repl(client: socket.socket): 291 | """ 292 | Handles connection. 293 | """ 294 | client.settimeout(None) 295 | # # disable this till we have evidence that it's needed 296 | # client.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 0) 297 | # # Note: setting SO_RCVBUF on UDS has no effect, see: http://man7.org/linux/man-pages/man7/unix.7.html 298 | 299 | backup = [] 300 | old_interval = getinterval() 301 | patches = [('r', ('stdin', '__stdin__')), ('w', ('stdout', '__stdout__'))] 302 | if _MANHOLE.redirect_stderr: 303 | patches.append(('w', ('stderr', '__stderr__'))) 304 | try: 305 | for mode, names in patches: 306 | for name in names: 307 | backup.append((name, backup_fh := getattr(sys, name))) 308 | setattr(sys, name, wrapped_fh := TextIOWrapper(client.makefile(f'{mode}b', 0), encoding=backup_fh.encoding)) 309 | wrapped_fh.mode = mode 310 | 311 | try: 312 | handle_repl(_MANHOLE.locals) 313 | except BrokenPipeError: 314 | _LOG('REPL client disconnected') 315 | except Exception as exc: 316 | _LOG(f'REPL failed with {exc!r}.') 317 | _LOG('DONE.') 318 | finally: 319 | try: 320 | # Change the switch/check interval to something ridiculous. We don't want to have other thread try 321 | # to write to the redirected sys.__std*/sys.std* - it would fail horribly. 322 | setinterval(2147483647) 323 | 324 | for name, fh in backup: 325 | try: 326 | getattr(sys, name).close() 327 | except OSError: 328 | pass 329 | setattr(sys, name, fh) 330 | try: 331 | client.close() 332 | except OSError: 333 | pass 334 | finally: 335 | setinterval(old_interval) 336 | _LOG('Cleaned up.') 337 | 338 | 339 | _CONNECTION_HANDLER_ALIASES = {'repl': handle_connection_repl, 'exec': handle_connection_exec} 340 | 341 | 342 | class ManholeConsole(code.InteractiveConsole): 343 | def __init__(self, *args, **kw): 344 | code.InteractiveConsole.__init__(self, *args, **kw) 345 | if _MANHOLE.redirect_stderr: 346 | self.file = sys.stderr 347 | else: 348 | self.file = sys.stdout 349 | 350 | def write(self, data): 351 | self.file.write(data) 352 | 353 | 354 | def handle_repl(locals): 355 | """ 356 | Dumps stacktraces and runs an interactive prompt (REPL). 357 | """ 358 | dump_stacktraces() 359 | namespace = { 360 | 'dump_stacktraces': dump_stacktraces, 361 | 'sys': sys, 362 | 'os': os, 363 | 'socket': socket, 364 | 'traceback': traceback, 365 | } 366 | if locals: 367 | namespace.update(locals) 368 | try: 369 | ManholeConsole(namespace).interact() 370 | except SystemExit: 371 | pass 372 | finally: 373 | for attribute in ['last_type', 'last_value', 'last_traceback']: 374 | try: 375 | delattr(sys, attribute) 376 | except AttributeError: 377 | pass 378 | 379 | 380 | class Logger: 381 | """ 382 | Internal object used for logging. 383 | 384 | Initially this is not configured. Until you call ``manhole.install()``, this logger object won't work (will raise 385 | ``NotInstalled``). 386 | """ 387 | 388 | time = _get_original('time', 'time') 389 | enabled = True 390 | destination = None 391 | 392 | def configure(self, enabled, destination): 393 | self.enabled = enabled 394 | self.destination = destination 395 | 396 | def release(self): 397 | self.enabled = True 398 | self.destination = None 399 | 400 | def __call__(self, message): 401 | """ 402 | Fail-ignorant logging function. 403 | """ 404 | if self.enabled: 405 | if self.destination is None: 406 | raise NotInstalled('Manhole is not installed!') 407 | try: 408 | full_message = f'Manhole[{os.getpid()}:{self.time():.4f}]: {message}\n' 409 | 410 | if isinstance(self.destination, int): 411 | os.write(self.destination, full_message.encode('ascii', 'ignore')) 412 | else: 413 | self.destination.write(full_message) 414 | except Exception: 415 | pass 416 | 417 | 418 | _LOG = Logger() 419 | 420 | 421 | class Manhole: 422 | # Manhole core configuration 423 | # These are initialized when manhole is installed. 424 | daemon_connection = False 425 | locals = None 426 | original_os_fork = None 427 | original_os_forkpty = None 428 | redirect_stderr = True 429 | reinstall_delay = 0.5 430 | should_restart = None 431 | sigmask = _ALL_SIGNALS 432 | socket_path = None 433 | start_timeout = 0.5 434 | connection_handler = None 435 | previous_signal_handlers = None 436 | _thread = None 437 | 438 | def configure( 439 | self, 440 | patch_fork=True, 441 | activate_on=None, 442 | sigmask=_ALL_SIGNALS, 443 | oneshot_on=None, 444 | thread=True, 445 | start_timeout=0.5, 446 | socket_path=None, 447 | reinstall_delay=0.5, 448 | locals=None, 449 | daemon_connection=False, 450 | redirect_stderr=True, 451 | connection_handler=handle_connection_repl, 452 | ): 453 | self.socket_path = socket_path 454 | self.reinstall_delay = reinstall_delay 455 | self.redirect_stderr = redirect_stderr 456 | self.locals = locals 457 | self.sigmask = sigmask 458 | self.daemon_connection = daemon_connection 459 | self.start_timeout = start_timeout 460 | self.previous_signal_handlers = {} 461 | self.connection_handler = _CONNECTION_HANDLER_ALIASES.get(connection_handler, connection_handler) 462 | 463 | if oneshot_on is None and activate_on is None and thread: 464 | self.thread.start() 465 | self.should_restart = True 466 | 467 | if oneshot_on is not None: 468 | oneshot_on = getattr(signal, 'SIG' + oneshot_on) if isinstance(oneshot_on, string) else oneshot_on 469 | self.previous_signal_handlers.setdefault(oneshot_on, signal.signal(oneshot_on, self.handle_oneshot)) 470 | 471 | if activate_on is not None: 472 | activate_on = getattr(signal, 'SIG' + activate_on) if isinstance(activate_on, string) else activate_on 473 | if activate_on == oneshot_on: 474 | raise ConfigurationConflict( 475 | 'You cannot do activation of the Manhole thread on the same signal ' 'that you want to do oneshot activation !' 476 | ) 477 | self.previous_signal_handlers.setdefault(activate_on, signal.signal(activate_on, self.activate_on_signal)) 478 | 479 | atexit.register(self.remove_manhole_uds) 480 | if patch_fork: 481 | if activate_on is None and oneshot_on is None and socket_path is None: 482 | self.patch_os_fork_functions() 483 | else: 484 | if activate_on: 485 | _LOG(f'Not patching os.fork and os.forkpty. Activation is done by signal {activate_on}') 486 | elif oneshot_on: 487 | _LOG(f'Not patching os.fork and os.forkpty. Oneshot activation is done by signal {oneshot_on}') 488 | elif socket_path: 489 | _LOG(f'Not patching os.fork and os.forkpty. Using user socket path {socket_path}') 490 | 491 | def release(self): 492 | if self._thread: 493 | self._thread.stop() 494 | self._thread = None 495 | self.remove_manhole_uds() 496 | self.restore_os_fork_functions() 497 | for sig, handler in self.previous_signal_handlers.items(): 498 | signal.signal(sig, handler) 499 | self.previous_signal_handlers.clear() 500 | 501 | @property 502 | def thread(self): 503 | if self._thread is None: 504 | self._thread = ManholeThread( 505 | self.get_socket, self.sigmask, self.start_timeout, self.connection_handler, daemon_connection=self.daemon_connection 506 | ) 507 | return self._thread 508 | 509 | @thread.setter 510 | def thread(self, value): 511 | self._thread = value 512 | 513 | def get_socket(self): 514 | sock = _ORIGINAL_SOCKET(socket.AF_UNIX, socket.SOCK_STREAM) 515 | name = self.remove_manhole_uds() 516 | sock.bind(name) 517 | sock.listen(5) 518 | _LOG('Manhole UDS path: ' + name) 519 | return sock 520 | 521 | def reinstall(self): 522 | """ 523 | Reinstalls the manhole. Checks if the thread is running. If not, it starts it again. 524 | """ 525 | with _LOCK: 526 | if not (self.thread.is_alive() and self.thread in _ORIGINAL__ACTIVE): 527 | self.thread = self.thread.clone(bind_delay=self.reinstall_delay) 528 | if self.should_restart: 529 | self.thread.start() 530 | 531 | def handle_oneshot(self, _signum=None, _frame=None): 532 | try: 533 | try: 534 | sock = self.get_socket() 535 | _LOG(f'Waiting for new connection (in pid:{os.getpid()}) ...') 536 | client = force_original_socket(sock.accept()[0]) 537 | check_credentials(client) 538 | self.connection_handler(client) 539 | finally: 540 | self.remove_manhole_uds() 541 | except BaseException as exc: # pylint: disable=W0702 542 | # we don't want to let any exception out, it might make the application misbehave 543 | _LOG(f'Oneshot failure: {exc!r}') 544 | 545 | def remove_manhole_uds(self): 546 | name = self.uds_name 547 | if os.path.exists(name): 548 | os.unlink(name) 549 | return name 550 | 551 | @property 552 | def uds_name(self): 553 | if self.socket_path is None: 554 | return f'/tmp/manhole-{os.getpid()}' 555 | return self.socket_path 556 | 557 | def patched_fork(self): 558 | """Fork a child process.""" 559 | pid = self.original_os_fork() 560 | if not pid: 561 | _LOG('Fork detected. Reinstalling Manhole.') 562 | self.reinstall() 563 | return pid 564 | 565 | def patched_forkpty(self): 566 | """Fork a new process with a new pseudo-terminal as controlling tty.""" 567 | pid, master_fd = self.original_os_forkpty() 568 | if not pid: 569 | _LOG('Fork detected. Reinstalling Manhole.') 570 | self.reinstall() 571 | return pid, master_fd 572 | 573 | def patch_os_fork_functions(self): 574 | self.original_os_fork, os.fork = os.fork, self.patched_fork 575 | self.original_os_forkpty, os.forkpty = os.forkpty, self.patched_forkpty 576 | _LOG(f'Patched {self.original_os_fork} and {self.original_os_forkpty}.') 577 | 578 | def restore_os_fork_functions(self): 579 | if self.original_os_fork: 580 | os.fork = self.original_os_fork 581 | if self.original_os_forkpty: 582 | os.forkpty = self.original_os_forkpty 583 | 584 | def activate_on_signal(self, _signum, _frame): 585 | self.thread.start() 586 | 587 | 588 | def install( 589 | verbose=True, 590 | verbose_destination=sys.__stderr__.fileno() if hasattr(sys.__stderr__, 'fileno') else sys.__stderr__, 591 | strict=True, 592 | **kwargs, 593 | ): 594 | """ 595 | Installs the manhole. 596 | 597 | Args: 598 | verbose (bool): Set it to ``False`` to squelch the logging. 599 | verbose_destination (file descriptor or handle): Destination for verbose messages. Default is unbuffered stderr 600 | (stderr ``2`` file descriptor). 601 | patch_fork (bool): Set it to ``False`` if you don't want your ``os.fork`` and ``os.forkpy`` monkeypatched 602 | activate_on (int or signal name): set to ``"USR1"``, ``"USR2"`` or some other signal name, or a number if you 603 | want the Manhole thread to start when this signal is sent. This is desireable in case you don't want the 604 | thread active all the time. 605 | oneshot_on (int or signal name): Set to ``"USR1"``, ``"USR2"`` or some other signal name, or a number if you 606 | want the Manhole to listen for connection in the signal handler. This is desireable in case you don't want 607 | threads at all. 608 | thread (bool): Start the always-on ManholeThread. Default: ``True``. Automatically switched to ``False`` if 609 | ``oneshort_on`` or ``activate_on`` are used. 610 | sigmask (list of ints or signal names): Will set the signal mask to the given list (using 611 | ``signalfd.sigprocmask``). No action is done if ``signalfd`` is not importable. 612 | **NOTE**: This is done so that the Manhole thread doesn't *steal* any signals; Normally that is fine because 613 | Python will force all the signal handling to be run in the main thread but signalfd doesn't. 614 | socket_path (str): Use a specific path for the unix domain socket (instead of ``/tmp/manhole-``). This 615 | disables ``patch_fork`` as children cannot reuse the same path. 616 | reinstall_delay (float): Delay the unix domain socket creation *reinstall_delay* seconds. This 617 | alleviates cleanup failures when using fork+exec patterns. 618 | locals (dict): Names to add to manhole interactive shell locals. 619 | daemon_connection (bool): The connection thread is daemonic (dies on app exit). Default: ``False``. 620 | redirect_stderr (bool): Redirect output from stderr to manhole console. Default: ``True``. 621 | connection_handler (function): Connection handler to use. Use ``"exec"`` for simple implementation without 622 | output redirection or your own function. (warning: this is for advanced users). Default: ``"repl"``. 623 | """ 624 | # pylint: disable=W0603 625 | global _MANHOLE 626 | 627 | with _LOCK: 628 | if _MANHOLE is None: 629 | _MANHOLE = Manhole() 630 | else: 631 | if strict: 632 | raise AlreadyInstalled('Manhole already installed!') 633 | else: 634 | _LOG.release() 635 | _MANHOLE.release() # Threads might be started here 636 | 637 | _LOG.configure(verbose, verbose_destination) 638 | _MANHOLE.configure(**kwargs) # Threads might be started here 639 | return _MANHOLE 640 | 641 | 642 | def dump_stacktraces(): 643 | """ 644 | Dumps thread ids and tracebacks to stdout. 645 | """ 646 | lines = [] 647 | for thread_id, stack in sys._current_frames().items(): # pylint: disable=W0212 648 | lines.append(f'\n######### ProcessID={os.getpid()}, ThreadID={thread_id} #########') 649 | for filename, lineno, name, line in traceback.extract_stack(stack): 650 | lines.append('File: "%s", line %d, in %s' % (filename, lineno, name)) 651 | if line: 652 | lines.append(f' {line.strip()}') 653 | lines.append('#############################################\n\n') 654 | 655 | print('\n'.join(lines), file=sys.stderr if _MANHOLE.redirect_stderr else sys.stdout) 656 | -------------------------------------------------------------------------------- /src/manhole/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import errno 5 | import os 6 | import re 7 | import readline 8 | import signal 9 | import socket 10 | import sys 11 | import threading 12 | import time 13 | 14 | try: 15 | input = raw_input 16 | except NameError: 17 | pass 18 | 19 | SIG_NAMES = {} 20 | SIG_NUMBERS = set() 21 | for sig, num in vars(signal).items(): 22 | if sig.startswith('SIG') and '_' not in sig: 23 | SIG_NAMES[sig] = num 24 | SIG_NAMES[sig[3:]] = num 25 | SIG_NUMBERS.add(num) 26 | 27 | 28 | def parse_pid(value, regex=re.compile(r'^(.*/manhole-)?(?P\d+)$')): 29 | match = regex.match(value) 30 | if not match: 31 | raise argparse.ArgumentTypeError('PID must be in one of these forms: 1234 or /tmp/manhole-1234') 32 | 33 | return int(match.group('pid')) 34 | 35 | 36 | def parse_signal(value): 37 | try: 38 | value = int(value) 39 | except ValueError: 40 | pass 41 | else: 42 | if value in SIG_NUMBERS: 43 | return value 44 | else: 45 | raise argparse.ArgumentTypeError( 46 | 'Invalid signal number {}. Expected one of: {}'.format(value, ', '.join(str(i) for i in SIG_NUMBERS)) 47 | ) 48 | value = value.upper() 49 | if value in SIG_NAMES: 50 | return SIG_NAMES[value] 51 | else: 52 | raise argparse.ArgumentTypeError(f'Invalid signal name {value!r}.') 53 | 54 | 55 | parser = argparse.ArgumentParser(description='Connect to a manhole.') 56 | parser.add_argument( 57 | 'pid', 58 | metavar='PID', 59 | type=parse_pid, 60 | help='A numerical process id, or a path in the form: /tmp/manhole-1234', # nargs='?', 61 | ) 62 | parser.add_argument('-t', '--timeout', dest='timeout', default=1, type=float, help='Timeout to use. Default: %(default)s seconds.') 63 | group = parser.add_mutually_exclusive_group() 64 | group.add_argument( 65 | '-1', 66 | '-USR1', 67 | dest='signal', 68 | action='store_const', 69 | const=int(signal.SIGUSR1), 70 | help='Send USR1 (%(const)s) to the process before connecting.', 71 | ) 72 | group.add_argument( 73 | '-2', 74 | '-USR2', 75 | dest='signal', 76 | action='store_const', 77 | const=int(signal.SIGUSR2), 78 | help='Send USR2 (%(const)s) to the process before connecting.', 79 | ) 80 | group.add_argument( 81 | '-s', '--signal', dest='signal', type=parse_signal, metavar='SIGNAL', help='Send the given SIGNAL to the process before connecting.' 82 | ) 83 | 84 | 85 | class ConnectionHandler(threading.Thread): 86 | def __init__(self, sock, is_closing): 87 | super().__init__() 88 | self.sock = sock 89 | self.is_closing = is_closing 90 | 91 | def run(self): 92 | while True: 93 | try: 94 | data = self.sock.recv(1024**2) 95 | if not data: 96 | break 97 | sys.stdout.write(data.decode('utf8')) 98 | sys.stdout.flush() 99 | readline.redisplay() 100 | except socket.timeout: 101 | pass 102 | 103 | if not self.is_closing.is_set(): 104 | # Break waiting for input() 105 | os.kill(os.getpid(), signal.SIGINT) 106 | 107 | 108 | def main(): 109 | args = parser.parse_args() 110 | 111 | histfile = os.path.join(os.path.expanduser('~'), '.manhole_history') 112 | try: 113 | readline.read_history_file(histfile) 114 | except OSError: 115 | pass 116 | import atexit 117 | 118 | atexit.register(readline.write_history_file, histfile) 119 | del histfile 120 | 121 | if args.signal: 122 | os.kill(args.pid, args.signal) 123 | 124 | start = time.time() 125 | uds_path = f'/tmp/manhole-{args.pid}' 126 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 127 | sock.settimeout(args.timeout) 128 | while time.time() - start < args.timeout: 129 | try: 130 | sock.connect(uds_path) 131 | except Exception as exc: 132 | if exc.errno not in (errno.ENOENT, errno.ECONNREFUSED): 133 | print(f'Failed to connect to {uds_path!r}: {exc!r}', file=sys.stderr) 134 | else: 135 | break 136 | else: 137 | print(f'Failed to connect to {uds_path!r}: Timeout', file=sys.stderr) 138 | sys.exit(5) 139 | 140 | is_closing = threading.Event() 141 | thread = ConnectionHandler(sock, is_closing) 142 | thread.start() 143 | 144 | try: 145 | while thread.is_alive(): 146 | data = input() 147 | data += '\n' 148 | sock.sendall(data.encode('utf8')) 149 | except (EOFError, KeyboardInterrupt): 150 | pass 151 | finally: 152 | is_closing.set() 153 | sock.shutdown(socket.SHUT_WR) 154 | thread.join() 155 | sock.close() 156 | -------------------------------------------------------------------------------- /tests/helper.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import errno 3 | import logging 4 | import os 5 | import signal 6 | import sys 7 | import time 8 | from functools import partial 9 | from typing import ClassVar 10 | 11 | TIMEOUT = int(os.getenv('MANHOLE_TEST_TIMEOUT', 10)) 12 | SOCKET_PATH = '/tmp/manhole-socket' 13 | OUTPUT = sys.__stdout__ 14 | 15 | 16 | def handle_sigterm(signo, _frame): 17 | # Simulate real termination 18 | print('Terminated', file=OUTPUT) 19 | sys.exit(128 + signo) 20 | 21 | 22 | # Handling sigterm ensure that atexit functions are called, and we do not leave 23 | # leftover /tmp/manhole-pid sockets. 24 | signal.signal(signal.SIGTERM, handle_sigterm) 25 | 26 | 27 | @atexit.register 28 | def log_exit(): 29 | print('In atexit handler.', file=OUTPUT) 30 | 31 | 32 | def setup_greenthreads(patch_threads=False): 33 | try: 34 | from gevent import monkey 35 | 36 | monkey.patch_all(thread=False) 37 | except (ImportError, SyntaxError): 38 | pass 39 | 40 | try: 41 | import eventlet 42 | 43 | eventlet.hubs.get_hub() # workaround for circular import issue in eventlet, 44 | # see https://github.com/eventlet/eventlet/issues/401 45 | eventlet.monkey_patch(thread=False) 46 | except (ImportError, SyntaxError): 47 | pass 48 | 49 | 50 | def do_fork(): 51 | pid = os.fork() 52 | if pid: 53 | 54 | @atexit.register 55 | def cleanup(): 56 | try: 57 | os.kill(pid, signal.SIGINT) 58 | time.sleep(0.2) 59 | os.kill(pid, signal.SIGTERM) 60 | except OSError as e: 61 | if e.errno != errno.ESRCH: 62 | raise 63 | 64 | os.waitpid(pid, 0) 65 | else: 66 | time.sleep(TIMEOUT * 10) 67 | 68 | 69 | if __name__ == '__main__': 70 | logging.basicConfig( 71 | level=logging.DEBUG, 72 | format='[pid=%(process)d - %(asctime)s]: %(name)s - %(levelname)s - %(message)s', 73 | ) 74 | test_name = sys.argv[1] 75 | try: 76 | if os.getenv('PATCH_THREAD', False): 77 | import manhole 78 | 79 | setup_greenthreads(True) 80 | else: 81 | setup_greenthreads(True) 82 | import manhole 83 | 84 | if test_name == 'test_environ_variable_activation': 85 | print(f'Sleeping {TIMEOUT} seconds...') 86 | time.sleep(TIMEOUT) 87 | elif test_name == 'test_install_twice_not_strict': 88 | manhole.install(oneshot_on='USR2') 89 | manhole.install(strict=False) 90 | time.sleep(TIMEOUT) 91 | elif test_name == 'test_unbuffered': 92 | manhole.install(verbose=True) 93 | print(os.getpid()) 94 | for i in range(5): 95 | time.sleep(1) 96 | print(f'line{i}') 97 | sys.stdout.flush() 98 | elif test_name == 'test_log_fd': 99 | manhole.install(verbose=True, verbose_destination=2) 100 | manhole._LOG('whatever-1') 101 | manhole._LOG('whatever-2') 102 | elif test_name == 'test_log_fh': 103 | 104 | class Output: 105 | data: ClassVar = [] 106 | write = data.append 107 | 108 | manhole.install(verbose=True, verbose_destination=Output) 109 | manhole._LOG('whatever') 110 | if Output.data and ']: whatever' in Output.data[-1]: 111 | print('SUCCESS') 112 | elif test_name == 'test_activate_on_usr2': 113 | manhole.install(activate_on='USR2') 114 | for _ in range(TIMEOUT * 100): 115 | time.sleep(0.1) 116 | elif test_name == 'test_install_once': 117 | manhole.install() 118 | try: 119 | manhole.install() 120 | except manhole.AlreadyInstalled: 121 | print('ALREADY_INSTALLED') 122 | else: 123 | raise AssertionError('Did not raise AlreadyInstalled') 124 | elif test_name == 'test_stderr_doesnt_deadlock': 125 | import subprocess 126 | 127 | manhole.install() 128 | 129 | for i in range(50): 130 | print('running iteration', i) 131 | p = subprocess.Popen(['true']) 132 | print('waiting for process', p.pid) 133 | p.wait() 134 | print('process ended') 135 | path = '/tmp/manhole-%d' % p.pid 136 | if os.path.exists(path): 137 | os.unlink(path) 138 | raise AssertionError(path + ' exists !') 139 | print('SUCCESS') 140 | elif test_name == 'test_fork_exec': 141 | manhole.install(reinstall_delay=5) 142 | print('Installed.') 143 | time.sleep(0.2) 144 | pid = os.fork() 145 | print('Forked, pid =', pid) 146 | if pid: 147 | os.waitpid(pid, 0) 148 | path = '/tmp/manhole-%d' % pid 149 | if os.path.exists(path): 150 | os.unlink(path) 151 | raise AssertionError(path + ' exists !') 152 | else: 153 | try: 154 | time.sleep(1) 155 | print('Exec-ing `true`') 156 | os.execvp('true', ['true']) 157 | finally: 158 | os._exit(1) 159 | print('SUCCESS') 160 | elif test_name == 'test_activate_on_with_oneshot_on': 161 | manhole.install(activate_on='USR2', oneshot_on='USR2') 162 | for _ in range(TIMEOUT * 100): 163 | time.sleep(0.1) 164 | elif test_name == 'test_interrupt_on_accept': 165 | 166 | def handle_usr2(_sig, _frame): 167 | print('Got USR2') 168 | 169 | signal.signal(signal.SIGUSR2, handle_usr2) 170 | 171 | import ctypes 172 | import ctypes.util 173 | 174 | libpthread_path = ctypes.util.find_library('pthread') 175 | if not libpthread_path: 176 | raise ImportError('ctypes.util.find_library("pthread") failed') 177 | libpthread = ctypes.CDLL(libpthread_path) 178 | if not hasattr(libpthread, 'pthread_setname_np'): 179 | raise ImportError('libpthread.pthread_setname_np missing') 180 | pthread_kill = libpthread.pthread_kill 181 | pthread_kill.argtypes = [ctypes.c_void_p, ctypes.c_int] 182 | pthread_kill.restype = ctypes.c_int 183 | manhole.install(sigmask=None) 184 | for _ in range(15): 185 | time.sleep(0.1) 186 | print('Sending signal to manhole thread ...') 187 | pthread_kill(manhole._MANHOLE.thread.ident, signal.SIGUSR2) 188 | for _ in range(TIMEOUT * 100): 189 | time.sleep(0.1) 190 | elif test_name == 'test_oneshot_on_usr2': 191 | manhole.install(oneshot_on='USR2') 192 | for _ in range(TIMEOUT * 100): 193 | time.sleep(0.1) 194 | elif test_name.startswith('test_signalfd_weirdness'): 195 | signalled = False 196 | 197 | @partial(signal.signal, signal.SIGUSR1) 198 | def signal_handler(sig, _): 199 | print(f'Received signal {sig}') 200 | global signalled 201 | signalled = True 202 | 203 | if 'negative' in test_name: 204 | manhole.install(sigmask=None) 205 | else: 206 | manhole.install(sigmask=[signal.SIGUSR1]) 207 | 208 | time.sleep(0.3) # give the manhole a bit enough time to start 209 | print('Starting ...') 210 | import signalfd 211 | 212 | signalfd.sigprocmask(signalfd.SIG_BLOCK, [signal.SIGUSR1]) 213 | sys.setcheckinterval(1) 214 | for _ in range(100000): 215 | os.kill(os.getpid(), signal.SIGUSR1) 216 | print(f'signalled={signalled}') 217 | time.sleep(TIMEOUT * 10) 218 | elif test_name == 'test_auth_fail': 219 | manhole.get_peercred = lambda _: (-1, -1, -1) 220 | manhole.install() 221 | time.sleep(TIMEOUT * 10) 222 | elif test_name == 'test_socket_path': 223 | manhole.install(socket_path=SOCKET_PATH) 224 | time.sleep(TIMEOUT * 10) 225 | elif test_name == 'test_daemon_connection': 226 | manhole.install(daemon_connection=True) 227 | time.sleep(TIMEOUT) 228 | elif test_name == 'test_socket_path_with_fork': 229 | manhole.install(socket_path=SOCKET_PATH) 230 | time.sleep(TIMEOUT) 231 | do_fork() 232 | elif test_name == 'test_locals': 233 | manhole.install(socket_path=SOCKET_PATH, locals={'k1': 'v1', 'k2': 'v2'}) 234 | time.sleep(TIMEOUT) 235 | elif test_name == 'test_locals_after_fork': 236 | manhole.install(locals={'k1': 'v1', 'k2': 'v2'}) 237 | do_fork() 238 | elif test_name == 'test_redirect_stderr_default': 239 | manhole.install(socket_path=SOCKET_PATH) 240 | time.sleep(TIMEOUT) 241 | elif test_name == 'test_redirect_stderr_disabled': 242 | manhole.install(socket_path=SOCKET_PATH, redirect_stderr=False) 243 | time.sleep(TIMEOUT) 244 | elif test_name == 'test_sigmask': 245 | manhole.install(socket_path=SOCKET_PATH, sigmask=[signal.SIGUSR1]) 246 | time.sleep(TIMEOUT) 247 | elif test_name == 'test_connection_handler_exec_func': 248 | manhole.install(connection_handler=manhole.handle_connection_exec, locals={'tete': lambda: print('TETE')}) 249 | time.sleep(TIMEOUT * 10) 250 | elif test_name == 'test_connection_handler_exec_str': 251 | manhole.install(connection_handler='exec', locals={'tete': lambda: print('TETE')}) 252 | time.sleep(TIMEOUT * 10) 253 | else: 254 | manhole.install() 255 | time.sleep(0.3) # give the manhole a bit enough time to start 256 | if test_name == 'test_simple': 257 | time.sleep(TIMEOUT * 10) 258 | elif test_name == 'test_with_forkpty': 259 | time.sleep(1) 260 | pid, masterfd = os.forkpty() 261 | if pid: 262 | 263 | @atexit.register 264 | def cleanup(): 265 | try: 266 | os.kill(pid, signal.SIGINT) 267 | time.sleep(0.2) 268 | os.kill(pid, signal.SIGTERM) 269 | except OSError as e: 270 | if e.errno != errno.ESRCH: 271 | raise 272 | 273 | while not os.waitpid(pid, os.WNOHANG)[0]: 274 | try: 275 | os.write(2, os.read(masterfd, 1024)) 276 | except OSError as e: 277 | print('Error while reading from masterfd:', e) 278 | else: 279 | time.sleep(TIMEOUT * 10) 280 | elif test_name == 'test_with_fork': 281 | time.sleep(1) 282 | do_fork() 283 | else: 284 | raise RuntimeError('Invalid test spec.') 285 | except: # noqa 286 | print(f'Died with {sys.exc_info()[0].__name__}.', file=OUTPUT) 287 | import traceback 288 | 289 | traceback.print_exc(file=OUTPUT) 290 | print('DIED.', file=OUTPUT) 291 | -------------------------------------------------------------------------------- /tests/test_manhole.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | import os 3 | import re 4 | import select 5 | import signal 6 | import socket 7 | import sys 8 | import time 9 | from contextlib import closing 10 | from ctypes.util import find_library 11 | 12 | import pytest 13 | import requests 14 | from process_tests import TestProcess 15 | from process_tests import TestSocket 16 | from process_tests import dump_on_error 17 | from process_tests import wait_for_strings 18 | 19 | TIMEOUT = int(os.getenv('MANHOLE_TEST_TIMEOUT', 10)) 20 | SOCKET_PATH = '/tmp/manhole-socket' 21 | HELPER = os.path.join(os.path.dirname(__file__), 'helper.py') 22 | 23 | 24 | def is_lib_available(lib): 25 | return find_library(lib) 26 | 27 | 28 | def is_module_available(mod): 29 | try: 30 | return importlib.util.find_spec(mod) 31 | except ImportError: 32 | return False 33 | 34 | 35 | def connect_to_manhole(uds_path): 36 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 37 | sock.settimeout(0.5) 38 | for i in range(TIMEOUT): 39 | try: 40 | sock.connect(uds_path) 41 | return sock 42 | except Exception as exc: 43 | print(f'Failed to connect to {uds_path}: {exc}') 44 | if i + 1 == TIMEOUT: 45 | sock.close() 46 | raise 47 | time.sleep(1) 48 | 49 | 50 | def assert_manhole_running(proc, uds_path, oneshot=False, extra=None): 51 | sock = connect_to_manhole(uds_path) 52 | with TestSocket(sock) as client: 53 | with dump_on_error(client.read): 54 | wait_for_strings(client.read, TIMEOUT, 'ProcessID', 'ThreadID', '>>>') 55 | sock.send(b"print('FOOBAR')\n") 56 | wait_for_strings(client.read, TIMEOUT, 'FOOBAR') 57 | wait_for_strings(proc.read, TIMEOUT, f'UID:{os.getuid()}') 58 | if extra: 59 | extra(client) 60 | wait_for_strings(proc.read, TIMEOUT, 'Cleaned up.', *[] if oneshot else ['Waiting for new connection']) 61 | 62 | 63 | def test_log_when_uninstalled(): 64 | import manhole 65 | 66 | pytest.raises(manhole.NotInstalled, manhole._LOG, 'whatever') 67 | 68 | 69 | def test_log_fd(capfd): 70 | with TestProcess(sys.executable, HELPER, 'test_log_fd') as proc: 71 | with dump_on_error(proc.read): 72 | wait_for_strings(proc.read, TIMEOUT, ']: whatever-1', ']: whatever-2') 73 | 74 | 75 | def test_log_fh(monkeypatch, capfd): 76 | with TestProcess(sys.executable, HELPER, 'test_log_fh') as proc: 77 | with dump_on_error(proc.read): 78 | wait_for_strings(proc.read, TIMEOUT, 'SUCCESS') 79 | 80 | 81 | def test_simple(): 82 | with TestProcess(sys.executable, HELPER, 'test_simple') as proc: 83 | with dump_on_error(proc.read): 84 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 85 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 86 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 87 | for _ in range(20): 88 | proc.buff.reset() 89 | assert_manhole_running(proc, uds_path) 90 | 91 | 92 | @pytest.mark.parametrize('variant', ['str', 'func']) 93 | def test_connection_handler_exec(variant): 94 | with TestProcess(sys.executable, HELPER, 'test_connection_handler_exec_' + variant) as proc: 95 | with dump_on_error(proc.read): 96 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 97 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 98 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 99 | for _ in range(200): 100 | proc.buff.reset() 101 | sock = connect_to_manhole(uds_path) 102 | wait_for_strings( 103 | proc.read, 104 | TIMEOUT, 105 | f'UID:{os.getuid()}', 106 | ) 107 | with TestSocket(sock) as client: 108 | with dump_on_error(client.read): 109 | sock.send(b"print('FOOBAR')\n") 110 | wait_for_strings(proc.read, TIMEOUT, 'FOOBAR') 111 | sock.send(b'tete()\n') 112 | wait_for_strings(proc.read, TIMEOUT, 'TETE') 113 | sock.send(b'exit()\n') 114 | wait_for_strings(proc.read, TIMEOUT, 'Exiting exec loop.') 115 | 116 | 117 | def test_install_once(): 118 | with TestProcess(sys.executable, HELPER, 'test_install_once') as proc: 119 | with dump_on_error(proc.read): 120 | wait_for_strings(proc.read, TIMEOUT, 'ALREADY_INSTALLED') 121 | 122 | 123 | def test_install_twice_not_strict(): 124 | with TestProcess(sys.executable, HELPER, 'test_install_twice_not_strict') as proc: 125 | with dump_on_error(proc.read): 126 | wait_for_strings(proc.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Oneshot activation is done by signal') 127 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 128 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 129 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 130 | assert_manhole_running(proc, uds_path) 131 | 132 | 133 | @pytest.mark.xfail('sys.gettrace() and is_module_available("gevent") and is_module_available("__pypy__")') 134 | def test_daemon_connection(): 135 | with TestProcess(sys.executable, HELPER, 'test_daemon_connection') as proc: 136 | with dump_on_error(proc.read): 137 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 138 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 139 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 140 | 141 | def terminate_and_read(client): 142 | proc.proc.send_signal(signal.SIGINT) 143 | wait_for_strings(proc.read, TIMEOUT, 'Died with KeyboardInterrupt', 'DIED.') 144 | for _ in range(5): 145 | client.sock.send(b'bogus()\n') 146 | time.sleep(0.05) 147 | print(repr(client.sock.recv(1024))) 148 | 149 | pytest.raises((socket.error, OSError), assert_manhole_running, proc, uds_path, extra=terminate_and_read) 150 | wait_for_strings(proc.read, TIMEOUT, 'In atexit handler') 151 | 152 | 153 | @pytest.mark.xfail( 154 | 'sys.gettrace() and is_module_available("gevent") and is_module_available("__pypy__") ' 'or is_module_available("eventlet") ' 155 | ) 156 | def test_non_daemon_connection(): 157 | with TestProcess(sys.executable, HELPER, 'test_simple') as proc: 158 | with dump_on_error(proc.read): 159 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 160 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 161 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 162 | 163 | def terminate_and_read(client): 164 | proc.proc.send_signal(signal.SIGINT) 165 | wait_for_strings(proc.read, TIMEOUT, 'Died with KeyboardInterrupt') 166 | client.sock.send(b'bogus()\n') 167 | wait_for_strings(client.read, TIMEOUT, 'bogus') 168 | client.sock.send(b'doofus()\n') 169 | wait_for_strings(client.read, TIMEOUT, 'doofus') 170 | 171 | assert_manhole_running(proc, uds_path, extra=terminate_and_read, oneshot=True) 172 | wait_for_strings(proc.read, TIMEOUT, 'In atexit handler') 173 | 174 | 175 | def test_locals(): 176 | with TestProcess(sys.executable, HELPER, 'test_locals') as proc: 177 | with dump_on_error(proc.read): 178 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 179 | check_locals(SOCKET_PATH) 180 | 181 | 182 | def test_locals_after_fork(): 183 | with TestProcess(sys.executable, HELPER, 'test_locals_after_fork') as proc: 184 | with dump_on_error(proc.read): 185 | wait_for_strings(proc.read, TIMEOUT, 'Fork detected') 186 | proc.buff.reset() 187 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 188 | child_uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 189 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 190 | check_locals(child_uds_path) 191 | 192 | 193 | def check_locals(uds_path): 194 | sock = connect_to_manhole(uds_path) 195 | with TestSocket(sock) as client: 196 | with dump_on_error(client.read): 197 | wait_for_strings(client.read, TIMEOUT, '>>>') 198 | sock.send(b'from __future__ import print_function\n' b'print(k1, k2)\n') 199 | wait_for_strings(client.read, TIMEOUT, 'v1 v2') 200 | 201 | 202 | def test_fork_exec(): 203 | with TestProcess(sys.executable, HELPER, 'test_fork_exec') as proc: 204 | with dump_on_error(proc.read): 205 | wait_for_strings(proc.read, TIMEOUT, 'SUCCESS') 206 | 207 | 208 | def test_socket_path(): 209 | with TestProcess(sys.executable, HELPER, 'test_socket_path') as proc: 210 | with dump_on_error(proc.read): 211 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 212 | proc.buff.reset() 213 | assert_manhole_running(proc, SOCKET_PATH) 214 | 215 | 216 | def test_socket_path_with_fork(): 217 | with TestProcess(sys.executable, '-u', HELPER, 'test_socket_path_with_fork') as proc: 218 | with dump_on_error(proc.read): 219 | wait_for_strings(proc.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Using user socket path') 220 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 221 | sock = connect_to_manhole(SOCKET_PATH) 222 | with TestSocket(sock) as client: 223 | with dump_on_error(client.read): 224 | wait_for_strings(client.read, TIMEOUT, 'ProcessID', 'ThreadID', '>>>') 225 | sock.send(b"print('BEFORE FORK')\n") 226 | wait_for_strings(client.read, TIMEOUT, 'BEFORE FORK') 227 | time.sleep(2) 228 | sock.send(b"print('AFTER FORK')\n") 229 | wait_for_strings(client.read, TIMEOUT, 'AFTER FORK') 230 | 231 | 232 | def test_redirect_stderr_default(): 233 | with TestProcess(sys.executable, HELPER, 'test_redirect_stderr_default') as proc: 234 | with dump_on_error(proc.read): 235 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 236 | sock = connect_to_manhole(SOCKET_PATH) 237 | with TestSocket(sock) as client: 238 | with dump_on_error(client.read): 239 | wait_for_strings(client.read, 1, '>>>') 240 | client.reset() 241 | sock.send(b'import sys\n' b"sys.stderr.write('OK')\n") 242 | wait_for_strings(client.read, 1, 'OK') 243 | 244 | 245 | def test_redirect_stderr_default_dump_stacktraces(): 246 | with TestProcess(sys.executable, HELPER, 'test_redirect_stderr_default') as proc: 247 | with dump_on_error(proc.read): 248 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 249 | check_dump_stacktraces(SOCKET_PATH) 250 | 251 | 252 | def test_redirect_stderr_default_print_tracebacks(): 253 | with TestProcess(sys.executable, HELPER, 'test_redirect_stderr_default') as proc: 254 | with dump_on_error(proc.read): 255 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 256 | check_print_tracebacks(SOCKET_PATH) 257 | 258 | 259 | def test_redirect_stderr_disabled(): 260 | with TestProcess(sys.executable, HELPER, 'test_redirect_stderr_disabled') as proc: 261 | with dump_on_error(proc.read): 262 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 263 | sock = connect_to_manhole(SOCKET_PATH) 264 | with TestSocket(sock) as client: 265 | with dump_on_error(client.read): 266 | wait_for_strings(client.read, 1, '>>>') 267 | client.reset() 268 | sock.send(b'import sys\n' b"sys.stderr.write('STDERR')\n" b"sys.stdout.write('STDOUT')\n") 269 | wait_for_strings(client.read, 1, 'STDOUT') 270 | assert 'STDERR' not in client.read() 271 | 272 | 273 | def test_redirect_stderr_disabled_dump_stacktraces(): 274 | with TestProcess(sys.executable, HELPER, 'test_redirect_stderr_disabled') as proc: 275 | with dump_on_error(proc.read): 276 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 277 | check_dump_stacktraces(SOCKET_PATH) 278 | 279 | 280 | def test_redirect_stderr_disabled_print_tracebacks(): 281 | with TestProcess(sys.executable, HELPER, 'test_redirect_stderr_disabled') as proc: 282 | with dump_on_error(proc.read): 283 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 284 | check_print_tracebacks(SOCKET_PATH) 285 | 286 | 287 | def check_dump_stacktraces(uds_path): 288 | sock = connect_to_manhole(uds_path) 289 | with TestSocket(sock) as client: 290 | with dump_on_error(client.read): 291 | wait_for_strings(client.read, 1, '>>>') 292 | sock.send(b'dump_stacktraces()\n') 293 | # Start of dump 294 | wait_for_strings(client.read, 1, '#########', 'ThreadID=', '#########') 295 | # End of dump 296 | wait_for_strings(client.read, 1, '#############################################', '>>>') 297 | 298 | 299 | def check_print_tracebacks(uds_path): 300 | sock = connect_to_manhole(uds_path) 301 | with TestSocket(sock) as client: 302 | with dump_on_error(client.read): 303 | wait_for_strings(client.read, 1, '>>>') 304 | sock.send(b'NO_SUCH_NAME\n') 305 | wait_for_strings(client.read, 1, 'NameError:', "name 'NO_SUCH_NAME' is not defined", '>>>') 306 | 307 | 308 | def test_exit_with_grace(): 309 | with TestProcess(sys.executable, '-u', HELPER, 'test_simple') as proc: 310 | with dump_on_error(proc.read): 311 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 312 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 313 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 314 | 315 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 316 | sock.settimeout(0.05) 317 | sock.connect(uds_path) 318 | with TestSocket(sock) as client: 319 | with dump_on_error(client.read): 320 | wait_for_strings(client.read, TIMEOUT, 'ThreadID', 'ProcessID', '>>>') 321 | sock.send(b"print('FOOBAR')\n") 322 | wait_for_strings(client.read, TIMEOUT, 'FOOBAR') 323 | 324 | wait_for_strings(proc.read, TIMEOUT, f'UID:{os.getuid()}') 325 | sock.shutdown(socket.SHUT_WR) 326 | select.select([sock], [], [], 5) 327 | sock.recv(1024) 328 | try: 329 | sock.shutdown(socket.SHUT_RD) 330 | except Exception as exc: 331 | print(f'Failed to SHUT_RD: {exc}') 332 | try: 333 | sock.close() 334 | except Exception as exc: 335 | print(f'Failed to close socket: {exc}') 336 | wait_for_strings(proc.read, TIMEOUT, 'DONE.', 'Cleaned up.', 'Waiting for new connection') 337 | 338 | 339 | def test_with_fork(): 340 | with TestProcess(sys.executable, '-u', HELPER, 'test_with_fork') as proc: 341 | with dump_on_error(proc.read): 342 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 343 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 344 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 345 | for _ in range(2): 346 | proc.buff.reset() 347 | assert_manhole_running(proc, uds_path) 348 | 349 | proc.buff.reset() 350 | wait_for_strings(proc.read, TIMEOUT, 'Fork detected') 351 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 352 | new_uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 353 | assert uds_path != new_uds_path 354 | 355 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 356 | for _ in range(2): 357 | proc.buff.reset() 358 | assert_manhole_running(proc, new_uds_path) 359 | 360 | 361 | def test_with_forkpty(): 362 | with TestProcess(sys.executable, '-u', HELPER, 'test_with_forkpty') as proc: 363 | with dump_on_error(proc.read): 364 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 365 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 366 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 367 | for _ in range(2): 368 | proc.buff.reset() 369 | assert_manhole_running(proc, uds_path) 370 | 371 | proc.buff.reset() 372 | wait_for_strings(proc.read, TIMEOUT, 'Fork detected') 373 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 374 | new_uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 375 | assert uds_path != new_uds_path 376 | 377 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 378 | for _ in range(2): 379 | proc.buff.reset() 380 | assert_manhole_running(proc, new_uds_path) 381 | 382 | 383 | def test_auth_fail(): 384 | with TestProcess(sys.executable, '-u', HELPER, 'test_auth_fail') as proc: 385 | with dump_on_error(proc.read): 386 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 387 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 388 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 389 | with closing(socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)) as sock: 390 | sock.settimeout(1) 391 | sock.connect(uds_path) 392 | try: 393 | assert b'' == sock.recv(1024) 394 | except socket.timeout: 395 | pass 396 | wait_for_strings( 397 | proc.read, 398 | TIMEOUT, 399 | "SuspiciousClient: Can't accept client with PID:-1 UID:-1 GID:-1. It doesn't match the current " 'EUID:', 400 | 'Waiting for new connection', 401 | ) 402 | proc.proc.send_signal(signal.SIGINT) 403 | 404 | 405 | @pytest.mark.skipif('not is_module_available("signalfd")') 406 | def test_sigprocmask(): 407 | with TestProcess(sys.executable, '-u', HELPER, 'test_signalfd_weirdness') as proc: 408 | with dump_on_error(proc.read): 409 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 410 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 411 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 412 | wait_for_strings(proc.read, TIMEOUT, 'signalled=False') 413 | assert_manhole_running(proc, uds_path) 414 | 415 | 416 | @pytest.mark.skipif('not is_module_available("signalfd")') 417 | def test_sigprocmask_negative(): 418 | with TestProcess(sys.executable, '-u', HELPER, 'test_signalfd_weirdness_negative') as proc: 419 | with dump_on_error(proc.read): 420 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 421 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 422 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 423 | wait_for_strings(proc.read, TIMEOUT, 'signalled=True') 424 | assert_manhole_running(proc, uds_path) 425 | 426 | 427 | def test_activate_on_usr2(): 428 | with TestProcess(sys.executable, '-u', HELPER, 'test_activate_on_usr2') as proc: 429 | with dump_on_error(proc.read): 430 | wait_for_strings(proc.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Activation is done by signal') 431 | pytest.raises(AssertionError, wait_for_strings, proc.read, TIMEOUT, '/tmp/manhole-') 432 | proc.signal(signal.SIGUSR2) 433 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 434 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 435 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 436 | assert_manhole_running(proc, uds_path) 437 | 438 | 439 | def test_activate_on_with_oneshot_on(): 440 | with TestProcess(sys.executable, '-u', HELPER, 'test_activate_on_with_oneshot_on') as proc: 441 | with dump_on_error(proc.read): 442 | wait_for_strings( 443 | proc.read, 444 | TIMEOUT, 445 | 'You cannot do activation of the Manhole thread on the same signal that you want to do ' 'oneshot activation !', 446 | ) 447 | 448 | 449 | def test_oneshot_on_usr2(): 450 | with TestProcess(sys.executable, '-u', HELPER, 'test_oneshot_on_usr2') as proc: 451 | with dump_on_error(proc.read): 452 | wait_for_strings(proc.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Oneshot activation is done by signal') 453 | pytest.raises(AssertionError, wait_for_strings, proc.read, TIMEOUT, '/tmp/manhole-') 454 | proc.signal(signal.SIGUSR2) 455 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 456 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 457 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 458 | assert_manhole_running(proc, uds_path, oneshot=True) 459 | 460 | 461 | def test_oneshot_on_usr2_error(): 462 | with TestProcess(sys.executable, '-u', HELPER, 'test_oneshot_on_usr2') as proc: 463 | with dump_on_error(proc.read): 464 | wait_for_strings(proc.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Oneshot activation is done by signal') 465 | pytest.raises(AssertionError, wait_for_strings, proc.read, TIMEOUT, '/tmp/manhole-') 466 | proc.signal(signal.SIGUSR2) 467 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 468 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 469 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 470 | assert_manhole_running(proc, uds_path, oneshot=True, extra=lambda client: client.sock.send(b'raise SystemExit()\n')) 471 | 472 | proc.buff.reset() 473 | proc.signal(signal.SIGUSR2) 474 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 475 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 476 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 477 | assert_manhole_running(proc, uds_path, oneshot=True) 478 | 479 | 480 | @pytest.mark.skipif('not is_lib_available("pthread")') 481 | def test_interrupt_on_accept(): 482 | with TestProcess(sys.executable, '-u', HELPER, 'test_interrupt_on_accept') as proc: 483 | with dump_on_error(proc.read): 484 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 485 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 486 | only_on_old_python = ['Waiting for new connection'] if sys.version_info < (3, 5) else [] 487 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection', 'Sending signal to manhole thread', *only_on_old_python) 488 | assert_manhole_running(proc, uds_path) 489 | 490 | 491 | def test_environ_variable_activation(): 492 | with TestProcess( 493 | sys.executable, 494 | '-u', 495 | HELPER, 496 | 'test_environ_variable_activation', 497 | env=dict(os.environ, PYTHONMANHOLE="oneshot_on='USR2',verbose=True"), 498 | ) as proc: 499 | with dump_on_error(proc.read): 500 | wait_for_strings(proc.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Oneshot activation is done by signal') 501 | proc.signal(signal.SIGUSR2) 502 | wait_for_strings(proc.read, TIMEOUT, '/tmp/manhole-') 503 | uds_path = re.findall(r'(/tmp/manhole-\d+)', proc.read())[0] 504 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 505 | assert_manhole_running(proc, uds_path, oneshot=True) 506 | 507 | 508 | @pytest.mark.skipif('not is_module_available("signalfd")') 509 | def test_sigmask(): 510 | with TestProcess(sys.executable, HELPER, 'test_sigmask') as proc: 511 | with dump_on_error(proc.read): 512 | wait_for_strings(proc.read, TIMEOUT, 'Waiting for new connection') 513 | sock = connect_to_manhole(SOCKET_PATH) 514 | with TestSocket(sock) as client: 515 | with dump_on_error(client.read): 516 | wait_for_strings(client.read, 1, '>>>') 517 | client.reset() 518 | # Python 2.7 returns [10L], Python 3 returns [10] 519 | sock.send( 520 | b'from __future__ import print_function\n' 521 | b'import signalfd\n' 522 | b'mask = signalfd.sigprocmask(signalfd.SIG_BLOCK, [])\n' 523 | b'print([int(n) for n in mask])\n' 524 | ) 525 | wait_for_strings(client.read, 1, '%s' % [int(signal.SIGUSR1)]) 526 | 527 | 528 | def test_stderr_doesnt_deadlock(): 529 | for _ in range(25 if is_module_available('__pypy__') else 100): 530 | with TestProcess(sys.executable, HELPER, 'test_stderr_doesnt_deadlock') as proc: 531 | with dump_on_error(proc.read): 532 | wait_for_strings(proc.read, TIMEOUT, 'SUCCESS') 533 | 534 | 535 | @pytest.mark.skipif('is_module_available("__pypy__")') 536 | def test_uwsgi(): 537 | with TestProcess( 538 | 'uwsgi', 539 | '--master', 540 | '--processes', 541 | '1', 542 | '--no-orphans', 543 | '--log-5xx', 544 | '--single-interpreter', 545 | '--shared-socket', 546 | ':0', 547 | '--no-default-app', 548 | '--manage-script-name', 549 | '--http', 550 | '=0', 551 | '--mount', 552 | '=wsgi:application', 553 | *['--virtualenv', os.environ['VIRTUAL_ENV']] if 'VIRTUAL_ENV' in os.environ else [], 554 | ) as proc: 555 | with dump_on_error(proc.read): 556 | wait_for_strings(proc.read, TIMEOUT, 'uWSGI http bound') 557 | port = re.findall(r'uWSGI http bound on :(\d+) fd', proc.read())[0] 558 | assert requests.get(f'http://127.0.0.1:{port}/', timeout=TIMEOUT).text == 'OK' 559 | 560 | wait_for_strings(proc.read, TIMEOUT, 'spawned uWSGI worker 1') 561 | pid = re.findall(r'spawned uWSGI worker 1 \(pid: (\d+), ', proc.read())[0] 562 | 563 | for retry in range(5)[::-1]: 564 | with open('/tmp/manhole-pid', 'w') as fh: 565 | fh.write(pid) 566 | try: 567 | assert_manhole_running(proc, f'/tmp/manhole-{pid}', oneshot=True) 568 | except Exception: 569 | if not retry: 570 | raise 571 | else: 572 | break 573 | -------------------------------------------------------------------------------- /tests/test_manhole_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import sys 4 | 5 | import pytest 6 | from process_tests import TestProcess 7 | from process_tests import dump_on_error 8 | from process_tests import wait_for_strings 9 | 10 | try: 11 | import subprocess32 as subprocess 12 | except ImportError: 13 | import subprocess 14 | 15 | TIMEOUT = int(os.getenv('MANHOLE_TEST_TIMEOUT', 10)) 16 | HELPER = os.path.join(os.path.dirname(__file__), 'helper.py') 17 | 18 | pytest_plugins = ('pytester',) 19 | 20 | 21 | def test_pid_validation(): 22 | exc = pytest.raises(subprocess.CalledProcessError, subprocess.check_output, ['manhole-cli', 'asdfasdf'], stderr=subprocess.STDOUT) 23 | assert ( 24 | exc.value.output 25 | == b"""usage: manhole-cli [-h] [-t TIMEOUT] [-1 | -2 | -s SIGNAL] PID 26 | manhole-cli: error: argument PID: PID must be in one of these forms: 1234 or /tmp/manhole-1234 27 | """ 28 | ) 29 | 30 | 31 | def test_sig_number_validation(): 32 | exc = pytest.raises( 33 | subprocess.CalledProcessError, subprocess.check_output, ['manhole-cli', '-s', '12341234', '12341234'], stderr=subprocess.STDOUT 34 | ) 35 | assert exc.value.output.startswith( 36 | b"""usage: manhole-cli [-h] [-t TIMEOUT] [-1 | -2 | -s SIGNAL] PID 37 | manhole-cli: error: argument -s/--signal: Invalid signal number 12341234. Expected one of: """ 38 | ) 39 | 40 | 41 | def test_help(testdir): 42 | result = testdir.run('manhole-cli', '--help') 43 | result.stdout.fnmatch_lines( 44 | [ 45 | 'usage: manhole-cli [-h] [-t TIMEOUT] [-1 | -2 | -s SIGNAL] PID', 46 | 'Connect to a manhole.', 47 | 'positional arguments:', 48 | ' PID A numerical process id, or a path in the form:*', 49 | ' -h, --help show this help message and exit', 50 | ' -t TIMEOUT, --timeout TIMEOUT', 51 | ' Timeout to use. Default: 1 seconds.', 52 | ' -1, -USR1 Send USR1 (*) to the process before connecting.', 53 | ' -2, -USR2 Send USR2 (*) to the process before connecting.', 54 | ' -s SIGNAL, --signal SIGNAL', 55 | ' Send the given SIGNAL to the process before*', 56 | ] 57 | ) 58 | 59 | 60 | def test_usr2(): 61 | with TestProcess(sys.executable, '-u', HELPER, 'test_oneshot_on_usr2') as service: 62 | with dump_on_error(service.read): 63 | wait_for_strings(service.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Oneshot activation is done by signal') 64 | with TestProcess('manhole-cli', '-USR2', str(service.proc.pid), bufsize=0, stdin=subprocess.PIPE) as client: 65 | with dump_on_error(client.read): 66 | wait_for_strings(client.read, TIMEOUT, '(ManholeConsole)', '>>>') 67 | client.proc.stdin.write('1234+2345\n') 68 | wait_for_strings(client.read, TIMEOUT, '3579') 69 | 70 | 71 | def test_pid(): 72 | with TestProcess(sys.executable, HELPER, 'test_simple') as service: 73 | with dump_on_error(service.read): 74 | wait_for_strings(service.read, TIMEOUT, '/tmp/manhole-') 75 | with TestProcess('manhole-cli', str(service.proc.pid), bufsize=0, stdin=subprocess.PIPE) as client: 76 | with dump_on_error(client.read): 77 | wait_for_strings(client.read, TIMEOUT, '(ManholeConsole)', '>>>') 78 | client.proc.stdin.write('1234+2345\n') 79 | wait_for_strings(client.read, TIMEOUT, '3579') 80 | 81 | 82 | def test_path(): 83 | with TestProcess(sys.executable, HELPER, 'test_simple') as service: 84 | with dump_on_error(service.read): 85 | wait_for_strings(service.read, TIMEOUT, '/tmp/manhole-') 86 | with TestProcess('manhole-cli', f'/tmp/manhole-{service.proc.pid}', bufsize=0, stdin=subprocess.PIPE) as client: 87 | with dump_on_error(client.read): 88 | wait_for_strings(client.read, TIMEOUT, '(ManholeConsole)', '>>>') 89 | client.proc.stdin.write('1234+2345\n') 90 | wait_for_strings(client.read, TIMEOUT, '3579') 91 | 92 | 93 | def test_sig_usr2(): 94 | with TestProcess(sys.executable, '-u', HELPER, 'test_oneshot_on_usr2') as service: 95 | with dump_on_error(service.read): 96 | wait_for_strings(service.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Oneshot activation is done by signal') 97 | with TestProcess('manhole-cli', '--signal=USR2', str(service.proc.pid), bufsize=0, stdin=subprocess.PIPE) as client: 98 | with dump_on_error(client.read): 99 | wait_for_strings(client.read, TIMEOUT, '(ManholeConsole)', '>>>') 100 | client.proc.stdin.write('1234+2345\n') 101 | wait_for_strings(client.read, TIMEOUT, '3579') 102 | 103 | 104 | def test_sig_usr2_full(): 105 | with TestProcess(sys.executable, '-u', HELPER, 'test_oneshot_on_usr2') as service: 106 | with dump_on_error(service.read): 107 | wait_for_strings(service.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Oneshot activation is done by signal') 108 | with TestProcess('manhole-cli', '-s', 'SIGUSR2', str(service.proc.pid), bufsize=0, stdin=subprocess.PIPE) as client: 109 | with dump_on_error(client.read): 110 | wait_for_strings(client.read, TIMEOUT, '(ManholeConsole)', '>>>') 111 | client.proc.stdin.write('1234+2345\n') 112 | wait_for_strings(client.read, TIMEOUT, '3579') 113 | 114 | 115 | def test_sig_usr2_number(): 116 | with TestProcess(sys.executable, '-u', HELPER, 'test_oneshot_on_usr2') as service: 117 | with dump_on_error(service.read): 118 | wait_for_strings(service.read, TIMEOUT, 'Not patching os.fork and os.forkpty. Oneshot activation is done by signal') 119 | with TestProcess( 120 | 'manhole-cli', '-s', str(int(signal.SIGUSR2)), str(service.proc.pid), bufsize=0, stdin=subprocess.PIPE 121 | ) as client: 122 | with dump_on_error(client.read): 123 | wait_for_strings(client.read, TIMEOUT, '(ManholeConsole)', '>>>') 124 | client.proc.stdin.write('1234+2345\n') 125 | wait_for_strings(client.read, TIMEOUT, '3579') 126 | 127 | 128 | def test_unbuffered(): 129 | with TestProcess(sys.executable, '-u', HELPER, 'test_unbuffered') as service: 130 | with dump_on_error(service.read): 131 | with TestProcess('manhole-cli', str(service.proc.pid), bufsize=0, stdin=subprocess.PIPE) as client: 132 | with dump_on_error(client.read): 133 | wait_for_strings(client.read, TIMEOUT, '(ManholeConsole)', '>>>') 134 | for i in range(5): 135 | wait_for_strings(client.read, 5, f'line{i}') 136 | -------------------------------------------------------------------------------- /tests/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import manhole 5 | 6 | stack_dump_file = '/tmp/manhole-pid' 7 | uwsgi_signal_number = 17 8 | 9 | try: 10 | import uwsgi 11 | 12 | if not os.path.exists(stack_dump_file): 13 | open(stack_dump_file, 'w') 14 | 15 | def open_manhole(dummy_signum): 16 | with open(stack_dump_file) as fh: 17 | pid = fh.read().strip() 18 | if pid == str(os.getpid()): 19 | inst = manhole.install(strict=False, thread=False) 20 | inst.handle_oneshot(dummy_signum, dummy_signum) 21 | 22 | uwsgi.register_signal(uwsgi_signal_number, 'workers', open_manhole) 23 | uwsgi.add_file_monitor(uwsgi_signal_number, stack_dump_file) 24 | 25 | print(f'Listening for stack manhole requests via {stack_dump_file!r}', file=sys.stderr) 26 | except ImportError: 27 | print('Not running under uwsgi; unable to configure manhole trigger', file=sys.stderr) 28 | except OSError: 29 | print(f'IOError creating manhole trigger {stack_dump_file!r}', file=sys.stderr) 30 | 31 | 32 | def application(env, sr): 33 | sr('200 OK', [('Content-Type', 'text/plain'), ('Content-Length', '2')]) 34 | yield b'OK' 35 | -------------------------------------------------------------------------------- /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}-{normal,signalfd}-{normal,gevent,eventlet}-{cover,nocov}, 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,codecov,coveralls}: {env:TOXPYTHON:python3} 32 | setenv = 33 | PYTHONPATH={toxinidir}/tests 34 | PYTHONUNBUFFERED=yes 35 | TERM=xterm 36 | passenv = 37 | * 38 | usedevelop = 39 | cover: true 40 | nocov: false 41 | deps = 42 | pytest 43 | cover: pytest-cov 44 | requests 45 | process-tests 46 | eventlet: eventlet==0.36.1 47 | gevent: gevent==24.2.1 48 | {py27,py36,py37,py38,py39,py310,py311,py312}: uwsgi==2.0.26 49 | commands = 50 | nocov: {posargs:pytest -vv --ignore=src} 51 | cover: {posargs:pytest --cov --cov-report=term-missing --cov-report=xml -vv} 52 | 53 | [testenv:check] 54 | deps = 55 | docutils 56 | check-manifest 57 | pre-commit 58 | readme-renderer 59 | pygments 60 | isort 61 | skip_install = true 62 | commands = 63 | python setup.py check --strict --metadata --restructuredtext 64 | check-manifest . 65 | pre-commit run --all-files --show-diff-on-failure 66 | 67 | [testenv:docs] 68 | usedevelop = true 69 | deps = 70 | -r{toxinidir}/docs/requirements.txt 71 | commands = 72 | sphinx-build {posargs:-E} -b html docs dist/docs 73 | sphinx-build -b linkcheck docs dist/docs 74 | 75 | [testenv:report] 76 | deps = 77 | coverage 78 | skip_install = true 79 | commands = 80 | coverage report 81 | coverage html 82 | 83 | [testenv:clean] 84 | commands = 85 | python setup.py clean 86 | coverage erase 87 | skip_install = true 88 | deps = 89 | setuptools 90 | coverage 91 | --------------------------------------------------------------------------------