├── .github ├── actions │ └── setup │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ └── dependabot-validate.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── .vscode └── settings.json ├── CHANGES.rst ├── CONTRIBUTION.md ├── LICENSE ├── Makefile ├── README.md ├── codecov.yml ├── doc ├── Makefile ├── _static │ └── .placeholder ├── api.rst ├── background.rst ├── changes.rst ├── conf.py ├── fixtures.rst ├── guide.rst ├── howto.rst ├── index.rst ├── patch.py ├── tutorial.rst └── upgrade.rst ├── example.py ├── example_pytest.py ├── poetry.lock ├── pyproject.toml ├── pytest_httpserver ├── __init__.py ├── blocking_httpserver.py ├── hooks.py ├── httpserver.py ├── py.typed └── pytest_plugin.py ├── release_checklist.md ├── releasenotes └── notes │ ├── additional-type-hints-improvements-b595896ca71b3537.yaml │ ├── blocking-httpserver-3b58e2a8464b4d97.yaml │ ├── build-minor-fixes-32955967c5948adc.yaml │ ├── daemon-thread-dc78e80bd74289dc.yaml │ ├── deprecation-warnings-for-1.0.0-b2b3909e5ad97594.yaml │ ├── environment-variables-8717eb8f545a8d3d.yaml │ ├── ephemeral-port-support-adea94cb8f6088c6.yaml │ ├── fix-16-query-string-c989bee73733a325.yaml │ ├── fix-log-leak-ee94a826d75173f1.yaml │ ├── fix-packaging-include-673c0b51ebdcd9a2.yaml │ ├── fix-test_verify_assert_msg-af37678f187bb8da.yaml │ ├── fix-x509-test-assets-8a1b220d085a504a.yaml │ ├── flake8-version-update-d70b88d9a15a723f.yaml │ ├── header-value-matcher-5b32c6640aef71d8.yaml │ ├── headervaluematcher-type-hinting-fix-f19e7a4d48b0a1d8.yaml │ ├── hooks-306915ded3b2771f.yaml │ ├── http-methods-are-case-insensitive-c2a1d49f9809f263.yaml │ ├── httpserver-listen-address-fixture-87f18b2cdbf47532.yaml │ ├── improved-error-reporting-in-custom-request-handlers-df479afb8eae03d1.yaml │ ├── initial-ef17767ee807ab89.yaml │ ├── json-matcher-1d030e3b4f9b66bd.yaml │ ├── log-querying-683219f3587d2139.yaml │ ├── minor-fixes-496615d00d2b3e44.yaml │ ├── mypy-fix-for-headervaluematcher-fba16bfa9dc3e0e4.yaml │ ├── new-expect-method-4f8d071c78c9884b.yaml │ ├── new-fixture-for-httpserver-making-98afa235d3283831.yaml │ ├── new-fixture-for-ssl-context-054da072a46e9e62.yaml │ ├── no-handler-status-code-380fa02ebe9b2721.yaml │ ├── nohandler-response-details-2af020f4763fcea8.yaml │ ├── poetry-5c16af5ed108ba9c.yaml │ ├── pytest-httpserver-listen-address-scope-change-0c1aa457e20cae83.yaml │ ├── python-34-35-deprecation-a4a3b57d1f2875d7.yaml │ ├── python-37-deprecation-72029b78e91d6b26.yaml │ ├── python-38-deprecation-48b0c8be245f63d1.yaml │ ├── python-classifier-fix-bfe43601d16f27d8.yaml │ ├── query-matcher-751db32b2ac1fc74.yaml │ ├── re-release-107-23f0fd429612b470.yaml │ ├── release-tag-fix-8b2dfc26a24598c3.yaml │ ├── requesthandler-repr-09f342f19f6250bc.yaml │ ├── same-as-1.0.0rc1-6356c8b1c488e3cd.yaml │ ├── sdist-new-files-d99db1317673be9c.yaml │ ├── setup-py-remove-pytest-runner-dd60d3f20ed45f1c.yaml │ ├── ssl-support-13321dd9d636af34.yaml │ ├── threading-support-28c89686025e2184.yaml │ ├── type-hints-improvement-02e0efd620644440.yaml │ ├── unify-expect-request-functions-bd877c586b62a294.yaml │ ├── uri-matching-dba6660cb0689402.yaml │ ├── use-ruff-for-linting-a0f446e9df39c719.yaml │ ├── werkzeug-header-type-follow-up-74a80dd03e6ca6db.yaml │ ├── werkzeug-parse_authorization_header-deprecation-fix-8264966b70fddc6d.yaml │ └── werkzeug-urls-url_decode-deprecation-fix-56fe3c183b53f83b.yaml ├── scripts └── release.py ├── shell.nix ├── tests ├── assets │ ├── Makefile │ ├── README │ ├── rootCA.cnf │ ├── rootCA.crt │ ├── rootCA.key │ ├── rootCA.srl │ ├── server.cnf │ ├── server.crt │ ├── server.csr │ ├── server.key │ └── v3.ext ├── conftest.py ├── examples │ ├── test_example_blocking_httpserver.py │ ├── test_example_query_params1.py │ ├── test_example_query_params2.py │ ├── test_howto_authorization_headers.py │ ├── test_howto_case_insensitive_matcher.py │ ├── test_howto_check.py │ ├── test_howto_check_handler_errors.py │ ├── test_howto_custom_handler.py │ ├── test_howto_custom_hooks.py │ ├── test_howto_custom_request_matcher.py │ ├── test_howto_header_value_matcher.py │ ├── test_howto_hooks.py │ ├── test_howto_json_matcher.py │ ├── test_howto_log_querying.py │ ├── test_howto_query_params_dict.py │ ├── test_howto_query_params_never_do_this.py │ ├── test_howto_query_params_proper_use.py │ ├── test_howto_regexp.py │ ├── test_howto_timeout_requests.py │ ├── test_howto_url_matcher.py │ └── test_howto_wait_success.py ├── test_blocking_httpserver.py ├── test_handler_errors.py ├── test_headers.py ├── test_hooks.py ├── test_ip_protocols.py ├── test_json_matcher.py ├── test_log_leak.py ├── test_log_querying.py ├── test_matcher.py ├── test_mixed.py ├── test_oneshot.py ├── test_ordered.py ├── test_parse_qs.py ├── test_permanent.py ├── test_port_changing.py ├── test_querymatcher.py ├── test_querystring.py ├── test_release.py ├── test_ssl.py ├── test_thread_type.py ├── test_threaded.py ├── test_urimatch.py ├── test_wait.py └── test_with_statement.py ├── tox.ini └── tox.nix /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: "setup poetry and python" 2 | description: "Setup python, poetry, and development environment" 3 | inputs: 4 | type: 5 | description: "Type of the venv to create" 6 | required: true 7 | python-version: 8 | description: "Python version to use" 9 | required: true 10 | poetry-version: 11 | description: "Poetry version to use" 12 | required: true 13 | 14 | runs: 15 | using: "composite" 16 | steps: 17 | - name: Setup python 18 | id: setup_python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ inputs.python-version }} 22 | 23 | - name: Cache venv 24 | id: cache-venv 25 | uses: actions/cache@v4 26 | with: 27 | path: .venv 28 | key: venv-v4-${{ inputs.type }}-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('poetry.lock') }} 29 | 30 | - name: Cache pre-commit 31 | id: cache-pre-commit 32 | uses: actions/cache@v4 33 | with: 34 | path: ~/.cache/pre-commit 35 | key: pre-commit-v4-${{ inputs.type }}-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} 36 | 37 | - name: Cache setup-poetry 38 | id: cache-setup-poetry 39 | uses: actions/cache@v4 40 | with: 41 | path: | 42 | ~/.local/share/pypoetry 43 | ~/.local/share/virtualenv 44 | ~/.local/bin/poetry 45 | key: setup-poetry-v4-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ inputs.poetry-version }} 46 | 47 | 48 | - name: Setup poetry 49 | uses: Gr1N/setup-poetry@v8 50 | id: setup_poetry 51 | if: steps.cache-setup-poetry.outputs.cache-hit != 'true' 52 | with: 53 | poetry-version: ${{ inputs.poetry-version }} 54 | 55 | - name: Bootstrap environment 56 | shell: bash 57 | run: | 58 | make dev EXTRAS=${{ inputs.type }} 59 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | 2 | version: 2 3 | updates: 4 | 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | groups: 10 | actions-deps: 11 | patterns: 12 | - "*" 13 | 14 | - package-ecosystem: "pip" 15 | directory: "/" 16 | schedule: 17 | interval: "monthly" 18 | groups: 19 | deps: 20 | patterns: 21 | - "*" 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | env: 12 | POETRY_VERSION: "1.8.4" 13 | 14 | jobs: 15 | cs: 16 | name: Coding style checks 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Setup 22 | uses: ./.github/actions/setup 23 | with: 24 | type: test 25 | python-version: "3.11" 26 | poetry-version: ${{ env.POETRY_VERSION }} 27 | 28 | 29 | - name: Style 30 | run: | 31 | make precommit 32 | 33 | - name: Lint 34 | run: | 35 | make mypy 36 | 37 | test: 38 | name: Test with python ${{ matrix.python-version }} / ${{ matrix.os-version }} 39 | strategy: 40 | matrix: 41 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 42 | os-version: ["ubuntu-latest", "windows-latest"] 43 | exclude: 44 | - os-version: windows-latest 45 | include: 46 | - os-version: windows-latest 47 | python-version: 3.13 48 | 49 | runs-on: ${{ matrix.os-version }} 50 | 51 | steps: 52 | 53 | - name: Checkout 54 | uses: actions/checkout@v4 55 | 56 | - name: Setup 57 | uses: ./.github/actions/setup 58 | with: 59 | type: test 60 | python-version: ${{ matrix.python-version }} 61 | poetry-version: ${{ env.POETRY_VERSION }} 62 | 63 | - name: Test 64 | if: runner.os == 'Linux' 65 | run: | 66 | make cov 67 | 68 | - name: Test 69 | if: runner.os == 'Windows' 70 | shell: bash 71 | env: 72 | PYTEST_HTTPSERVER_HOST: '127.0.0.1' 73 | run: | 74 | set -e 75 | poetry run pytest tests -s -vv --release 76 | poetry run pytest tests -s -vv --ssl 77 | 78 | 79 | - name: Codecov upload 80 | uses: codecov/codecov-action@v5 81 | if: runner.os == 'Linux' 82 | with: 83 | token: ${{ secrets.CODECOV_TOKEN }} 84 | 85 | test-doc: 86 | name: Test doc build 87 | runs-on: ubuntu-latest 88 | steps: 89 | - name: Checkout 90 | uses: actions/checkout@v4 91 | 92 | - name: Setup 93 | uses: ./.github/actions/setup 94 | with: 95 | type: doc 96 | python-version: "3.13" 97 | poetry-version: ${{ env.POETRY_VERSION }} 98 | 99 | - name: Test 100 | run: | 101 | make doc 102 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-validate.yml: -------------------------------------------------------------------------------- 1 | name: dependabot validate 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/dependabot.yml' 7 | - '.github/workflows/dependabot-validate.yml' 8 | jobs: 9 | validate: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: marocchino/validate-dependabot@v3 14 | id: validate 15 | - uses: marocchino/sticky-pull-request-comment@v2 16 | if: always() 17 | with: 18 | header: validate-dependabot 19 | message: ${{ steps.validate.outputs.markdown }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .pytest_cache/ 3 | .vscode/.ropeproject/ 4 | *.egg-info/ 5 | .cache/ 6 | .coverage 7 | coverage.xml 8 | htmlcov/ 9 | build/ 10 | dist/ 11 | .eggs/ 12 | doc/_build/ 13 | .tox/ 14 | .idea/ 15 | .python-version 16 | __pycache__/ 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.11 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --exit-non-zero-on-fix] 7 | 8 | - repo: https://github.com/psf/black 9 | rev: 25.1.0 10 | hooks: 11 | - id: black 12 | args: [--line-length=120, --safe] 13 | 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v5.0.0 16 | hooks: 17 | - id: check-case-conflict 18 | - id: check-merge-conflict 19 | - id: check-symlinks 20 | - id: end-of-file-fixer 21 | - id: trailing-whitespace 22 | - id: debug-statements 23 | - id: requirements-txt-fixer 24 | 25 | - repo: https://github.com/pycqa/isort 26 | rev: 6.0.1 27 | hooks: 28 | - id: isort 29 | name: isort (python) 30 | args: ['--force-single-line-imports', '--profile', 'black'] 31 | 32 | - repo: https://github.com/asottile/blacken-docs 33 | rev: 1.19.1 34 | hooks: 35 | - id: blacken-docs 36 | additional_dependencies: [ black ] 37 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3.11" 11 | jobs: 12 | post_create_environment: 13 | - pip install poetry 14 | 15 | post_install: 16 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with doc 17 | 18 | sphinx: 19 | configuration: doc/conf.py 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestEnabled": true, 3 | "python.testing.pytestPath": "${workspaceFolder}/.venv/bin/pytest", 4 | "python.testing.pytestArgs": [ 5 | "tests" 6 | ], 7 | "editor.formatOnSave": true, 8 | "isort.args": [ 9 | "--force-single-line-imports", 10 | "--profile", 11 | "black" 12 | ], 13 | "files.trimTrailingWhitespace": true, 14 | "json.format.enable": true, 15 | "files.insertFinalNewline": true, 16 | "nixEnvSelector.nixFile": "${workspaceRoot}/shell.nix", 17 | "[python]": { 18 | "editor.defaultFormatter": "ms-python.black-formatter" 19 | }, 20 | "ruff.enable": true, 21 | "ruff.codeAction.disableRuleComment": { 22 | "enable": true 23 | }, 24 | "ruff.codeAction.fixViolation": { 25 | "enable": true 26 | }, 27 | "ruff.organizeImports": true, 28 | "ruff.path": [ 29 | ".venv/bin/ruff" 30 | ], 31 | "black-formatter.args": [ 32 | "--line-length=120", 33 | "--safe" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Release Notes 3 | ============= 4 | 5 | .. _Release Notes_1.1.3: 6 | 7 | 1.1.3 8 | ===== 9 | 10 | .. _Release Notes_1.1.3_Bug Fixes: 11 | 12 | Bug Fixes 13 | --------- 14 | 15 | - Run server threads with daemon flag, preventing shutdown issues. 16 | `#411 `_ 17 | 18 | 19 | .. _Release Notes_1.1.2: 20 | 21 | 1.1.2 22 | ===== 23 | 24 | .. _Release Notes_1.1.2_Deprecation Notes: 25 | 26 | Deprecation Notes 27 | ----------------- 28 | 29 | - Python versions earlier than 3.9 have been deprecated in order to make the 30 | code more type safe. Python 3.8 has reached EOL on 2024-10-07. 31 | 32 | 33 | .. _Release Notes_1.1.1: 34 | 35 | 1.1.1 36 | ===== 37 | 38 | .. _Release Notes_1.1.1_New Features: 39 | 40 | New Features 41 | ------------ 42 | 43 | - Add a new ``expect`` method to the ``HTTPServer`` object which allows 44 | developers to provide their own request matcher object. 45 | 46 | 47 | .. _Release Notes_1.1.0: 48 | 49 | 1.1.0 50 | ===== 51 | 52 | .. _Release Notes_1.1.0_Bug Fixes: 53 | 54 | Bug Fixes 55 | --------- 56 | 57 | - Fixed an issue related to the leak of httpserver state between the tests 58 | when httpserver is destructed before the other fixtures. `#352 `_ 59 | 60 | 61 | .. _Release Notes_1.0.12: 62 | 63 | 1.0.12 64 | ====== 65 | 66 | .. _Release Notes_1.0.12_Bug Fixes: 67 | 68 | Bug Fixes 69 | --------- 70 | 71 | - Fix pytest-httpserver's own tests related to log querying. No functional 72 | changes in pytest-httpserver code itself. `#345 `_ 73 | 74 | 75 | .. _Release Notes_1.0.11: 76 | 77 | 1.0.11 78 | ====== 79 | 80 | .. _Release Notes_1.0.11_New Features: 81 | 82 | New Features 83 | ------------ 84 | 85 | - Hooks API 86 | 87 | - New methods added to query for matching requests in the log. 88 | 89 | - Threading support to serve requests in parallel 90 | 91 | 92 | .. _Release Notes_1.0.10: 93 | 94 | 1.0.10 95 | ====== 96 | 97 | .. _Release Notes_1.0.10_New Features: 98 | 99 | New Features 100 | ------------ 101 | 102 | - When there's no handler for the request, add more details to the response 103 | sent by the server about the request to help debugging. 104 | 105 | 106 | .. _Release Notes_1.0.10_Other Notes: 107 | 108 | Other Notes 109 | ----------- 110 | 111 | - Use ruff for linting. It includes some source code changes which should not 112 | introduce functional changes, or API changes. 113 | 114 | 115 | .. _Release Notes_1.0.9: 116 | 117 | 1.0.9 118 | ===== 119 | 120 | .. _Release Notes_1.0.9_New Features: 121 | 122 | New Features 123 | ------------ 124 | 125 | - Add ``__repr__`` to ``RequestHandler`` object so when it is compared (eg. with 126 | the ``log`` attribute of the server) it will show the matcher parameters. 127 | 128 | 129 | .. _Release Notes_1.0.8: 130 | 131 | 1.0.8 132 | ===== 133 | 134 | .. _Release Notes_1.0.8_Bug Fixes: 135 | 136 | Bug Fixes 137 | --------- 138 | 139 | - Version 1.0.7 has been released with incorrect dependencies. This is fixed now. 140 | 141 | 142 | .. _Release Notes_1.0.7: 143 | 144 | 1.0.7 145 | ===== 146 | 147 | .. _Release Notes_1.0.7_Upgrade Notes: 148 | 149 | Upgrade Notes 150 | ------------- 151 | 152 | - With werkzeug 2.3.x the headers type has been updated to not allow integers as header values. This restriction followed up in pytest-httpserver. 153 | 154 | 155 | .. _Release Notes_1.0.7_Deprecation Notes: 156 | 157 | Deprecation Notes 158 | ----------------- 159 | 160 | - Python versions earlier than 3.8 have been deprecated in order to support 161 | the latest werkzeug. Users using 3.7 or earlier python may use 162 | pytest-httpserver with earlier werkzeug versions but tests are no longer run 163 | for these python versions. 164 | 165 | 166 | .. _Release Notes_1.0.7_Bug Fixes: 167 | 168 | Bug Fixes 169 | --------- 170 | 171 | - Type hinting for header_value_matcher has been fixed. From now, specifying a 172 | callable as ``Callable[[str, Optional[str], str], bool]`` will be accepted 173 | also. Providing a ``HeaderValueMatcher`` object will be also accepted as 174 | before, as it provides the same callable signature. 175 | 176 | - Fix Werkzeug deprecation warning about ``parse_authorization_header`` call. 177 | Replace ``parse_authorization_header`` with ``Authorization.from_header`` as 178 | suggested. This fix should not introduce any functional change for the 179 | users. 180 | 181 | - Fix Werkzeug deprecation warning about ``werkzeug.urls.url_decode`` call. This 182 | call has been changed to ``urllib.parse.parse_qsl`` in the implementation. 183 | This fix should not introduce any functional change for the users. 184 | 185 | 186 | .. _Release Notes_1.0.6: 187 | 188 | 1.0.6 189 | ===== 190 | 191 | .. _Release Notes_1.0.6_New Features: 192 | 193 | New Features 194 | ------------ 195 | 196 | - Add a new way of running tests with the blocking mode. In this mode, the 197 | http server is synchronized to the main thread and the client code is run in 198 | a separate thread. 199 | 200 | 201 | .. _Release Notes_1.0.6_Bug Fixes: 202 | 203 | Bug Fixes 204 | --------- 205 | 206 | - Python version classifier updated in pyproject.toml (which updates pypi also) 207 | 208 | 209 | .. _Release Notes_1.0.5: 210 | 211 | 1.0.5 212 | ===== 213 | 214 | .. _Release Notes_1.0.5_Bug Fixes: 215 | 216 | Bug Fixes 217 | --------- 218 | 219 | - Packaging of sdist and the wheel fixed by adding the extra files only to the sdist and not to the wheel. 220 | 221 | 222 | .. _Release Notes_1.0.4: 223 | 224 | 1.0.4 225 | ===== 226 | 227 | .. _Release Notes_1.0.4_Bug Fixes: 228 | 229 | Bug Fixes 230 | --------- 231 | 232 | - Fixed type hinting of ``HeaderValueMatcher.DEFAULT_MATCHERS``, which did 233 | not allow modifications, however it is explicitly allowed in the documentation. 234 | 235 | 236 | .. _Release Notes_1.0.4_Other Notes: 237 | 238 | Other Notes 239 | ----------- 240 | 241 | - Version of flake8 library updated to require 4.0.0+ at minimum. This is 242 | required to make flake8 working on recent python versions. 243 | 244 | 245 | .. _Release Notes_1.0.3: 246 | 247 | 1.0.3 248 | ===== 249 | 250 | .. _Release Notes_1.0.3_New Features: 251 | 252 | New Features 253 | ------------ 254 | 255 | - Additional type hints improvements to make the library more mypy compliant. 256 | Imports in `__init__.py` have been updated to indicate that this is a 257 | namespace package. 258 | 259 | 260 | .. _Release Notes_1.0.3_Other Notes: 261 | 262 | Other Notes 263 | ----------- 264 | 265 | - Package deployment and CI has been migrated to poetry. poetry.lock will be kept 266 | up to date. Make target "quick-test" renamed to "test". Also, minor adjustments 267 | were made regarding documentation generation. Make targets should be identical. 268 | Build results like sdist, and wheel are almost identical to the one which was 269 | made by setuptools. 270 | 271 | 272 | .. _Release Notes_1.0.2: 273 | 274 | 1.0.2 275 | ===== 276 | 277 | .. _Release Notes_1.0.2_New Features: 278 | 279 | New Features 280 | ------------ 281 | 282 | - Type hints updated to conform to 'mypy' type checking tool. 283 | Also, py.typed file is added as package data according to PEP 561. 284 | 285 | 286 | .. _Release Notes_1.0.2_Deprecation Notes: 287 | 288 | Deprecation Notes 289 | ----------------- 290 | 291 | - Python 3.4 and 3.5 versions have been deprecated in order to support type 292 | hints in the source code. Users using 3.5 and earlier releases encouraged 293 | to upgrade to later versions. Please node that 3.5 reached EOL in September 294 | of 2020 and no longer receives security fixes. 295 | 296 | 297 | .. _Release Notes_1.0.1: 298 | 299 | 1.0.1 300 | ===== 301 | 302 | .. _Release Notes_1.0.1_New Features: 303 | 304 | New Features 305 | ------------ 306 | 307 | - Improved error handling of custom request handlers. Request handlers added 308 | with ``respond_with_handler`` now can use the ``assert`` statement. Those 309 | errors will be reported when a further ``check_assertions()`` call is made. 310 | Also, unhandled exceptions raised in the request handlers can be re-raised 311 | by calling the new ``check_handler_errors()`` method. A new method called 312 | ``check()`` has been added which calls these two in sequence. 313 | 314 | 315 | .. _Release Notes_1.0.0: 316 | 317 | 1.0.0 318 | ===== 319 | 320 | .. _Release Notes_1.0.0_Prelude: 321 | 322 | Prelude 323 | ------- 324 | 325 | Functionally the same as 1.0.0rc1. For the list of changes between 0.3.8 and 1.0.0 see the changelist for 1.0.0rc1. 326 | 327 | 328 | .. _Release Notes_1.0.0rc1: 329 | 330 | 1.0.0rc1 331 | ======== 332 | 333 | .. _Release Notes_1.0.0rc1_New Features: 334 | 335 | New Features 336 | ------------ 337 | 338 | - Added a new session scoped fixture ``make_httpserver`` which creates the 339 | object for the ``httpserver`` fixture. It can be overridden to add further 340 | customizations and it must yield a ``HTTPServer`` object - see 341 | ``pytest_plugin.py`` for an implementation. As this fixture is session 342 | scoped, it will be called only once when the first test using httpserver is 343 | started. This addition also deprecates the use of ``PluginHTTPServer`` which was 344 | used in earlier versions as a way to customize server object creation. 345 | ``PluginHTTPServer`` can still be used but it may be subject to deprecation 346 | in the future. 347 | 348 | - Added a new session scoped fixture ``httpserver_ssl_context`` which provides 349 | the SSL context for the server. By default it returns ``None``, so SSL is 350 | not enabled, but can be overridden to return a valid ``ssl.SSLContext`` 351 | object which will be used for SSL connections. See test_ssl.py for example. 352 | 353 | 354 | .. _Release Notes_1.0.0rc1_Upgrade Notes: 355 | 356 | Upgrade Notes 357 | ------------- 358 | 359 | - **Breaking change**: The scope of ``httpserver_listen_address`` fixture changed from **function** 360 | to **session**. This is a requirement to implement the other features listed 361 | in this release. See the `upgrade guide 362 | `_ for the 363 | details. 364 | 365 | 366 | .. _Release Notes_0.3.8: 367 | 368 | 0.3.8 369 | ===== 370 | 371 | .. _Release Notes_0.3.8_Deprecation Notes: 372 | 373 | Deprecation Notes 374 | ----------------- 375 | 376 | - Deprecation warnings were added to prepare changes to 1.0.0. More details: 377 | https://pytest-httpserver.readthedocs.io/en/latest/upgrade.html 378 | 379 | 380 | .. _Release Notes_0.3.7: 381 | 382 | 0.3.7 383 | ===== 384 | 385 | .. _Release Notes_0.3.7_Other Notes: 386 | 387 | Other Notes 388 | ----------- 389 | 390 | - Removed pytest-runner from setup.py as it is deprecated and makes packaging inconvenient 391 | as it needs to be installed before running setup.py. 392 | 393 | 394 | .. _Release Notes_0.3.6: 395 | 396 | 0.3.6 397 | ===== 398 | 399 | .. _Release Notes_0.3.6_New Features: 400 | 401 | New Features 402 | ------------ 403 | 404 | - HTTP methods are case insensitive. The HTTP method specified is converted to 405 | uppercase in the library. 406 | 407 | - It is now possible to specify a JSON-serializable python value (such as 408 | dict, list, etc) and match the request to it as JSON. The request's body 409 | is loaded as JSON and it will be compared to the expected value. 410 | 411 | - The http response code sent when no handler is found for the 412 | request can be changed. It is set to 500 by default. 413 | 414 | 415 | .. _Release Notes_0.3.5: 416 | 417 | 0.3.5 418 | ===== 419 | 420 | .. _Release Notes_0.3.5_New Features: 421 | 422 | New Features 423 | ------------ 424 | 425 | - Extend URI matching by allowing to specify URIPattern object or a compiled 426 | regular expression, which will be matched against the URI. URIPattern class 427 | is defined as abstract in the library so the user need to implement a new 428 | class based on it. 429 | 430 | 431 | .. _Release Notes_0.3.4: 432 | 433 | 0.3.4 434 | ===== 435 | 436 | .. _Release Notes_0.3.4_Bug Fixes: 437 | 438 | Bug Fixes 439 | --------- 440 | 441 | - Fix the tests assets created for SSL/TLS tests by extending their expiration time. Also 442 | update the Makefile which can be used to update these assets. 443 | 444 | 445 | .. _Release Notes_0.3.3: 446 | 447 | 0.3.3 448 | ===== 449 | 450 | .. _Release Notes_0.3.3_New Features: 451 | 452 | New Features 453 | ------------ 454 | 455 | - Besides bytes and string, dict and MultiDict objects can be specified as query_string. 456 | When these objects are used, the query string gets parsed into a dict (or MultiDict), 457 | and comparison is made accordingly. This enables the developer to ignore the order of 458 | the keys in the query_string when expecting a request. 459 | 460 | 461 | .. _Release Notes_0.3.3_Bug Fixes: 462 | 463 | Bug Fixes 464 | --------- 465 | 466 | - Fixed issue \#16 by converting string object passed as query_string 467 | to bytes which is the type of the query string in werkzeug, and also allowing 468 | bytes as the parameter. 469 | 470 | - Fix release tagging. 0.3.2 was released in a mistake by tagging 3.0.2 to the branch. 471 | 472 | 473 | .. _Release Notes_0.3.3_Other Notes: 474 | 475 | Other Notes 476 | ----------- 477 | 478 | - Add more files to source distribution (sdist). It now contains tests, 479 | assets, examples and other files. 480 | 481 | 482 | .. _Release Notes_0.3.1: 483 | 484 | 0.3.1 485 | ===== 486 | 487 | .. _Release Notes_0.3.1_New Features: 488 | 489 | New Features 490 | ------------ 491 | 492 | - Add httpserver_listen_address fixture which is used to set up the bind address and port 493 | of the server. Setting bind address and port is possible by overriding this fixture. 494 | 495 | 496 | .. _Release Notes_0.3.0: 497 | 498 | 0.3.0 499 | ===== 500 | 501 | .. _Release Notes_0.3.0_New Features: 502 | 503 | New Features 504 | ------------ 505 | 506 | - Support ephemeral port. This can be used by specify 0 as the port number 507 | to the HTTPServer instance. In such case, an unused port will be picked up 508 | and the server will start listening on that port. Querying the port attribute 509 | after server start reveals the real port where the server is actually listening. 510 | 511 | - Unify request functions of the HTTPServer class to make the API more straightforward to use. 512 | 513 | 514 | .. _Release Notes_0.3.0_Upgrade Notes: 515 | 516 | Upgrade Notes 517 | ------------- 518 | 519 | - The default port has been changed to 0, which results that the server will be staring 520 | on an ephemeral port. 521 | 522 | - The following methods of HTTPServer have been changed in a backward-incompatible way: 523 | * :py:meth:`pytest_httpserver.HTTPServer.expect_request` becomes a general function accepting handler_type parameter so it can create any kind of request handlers 524 | * :py:meth:`pytest_httpserver.HTTPServer.expect_oneshot_request` no longer accepts the ordered parameter, and it creates an unordered oneshot request handler 525 | * :py:meth:`pytest_httpserver.HTTPServer.expect_ordered_request` is a new method creating an ordered request handler 526 | 527 | 528 | .. _Release Notes_0.2.2: 529 | 530 | 0.2.2 531 | ===== 532 | 533 | .. _Release Notes_0.2.2_New Features: 534 | 535 | New Features 536 | ------------ 537 | 538 | - Make it possible to intelligently compare headers. To accomplish that 539 | HeaderValueMatcher was added. It already contains logic to compare 540 | unknown headers and authorization headers. Patch by Roman Inflianskas. 541 | 542 | 543 | .. _Release Notes_0.2.1: 544 | 545 | 0.2.1 546 | ===== 547 | 548 | .. _Release Notes_0.2.1_Prelude: 549 | 550 | Prelude 551 | ------- 552 | 553 | Minor fixes in setup.py and build environment. No actual code change in library .py files. 554 | 555 | 556 | .. _Release Notes_0.2: 557 | 558 | 0.2 559 | === 560 | 561 | .. _Release Notes_0.2_New Features: 562 | 563 | New Features 564 | ------------ 565 | 566 | - When using pytest plugin, specifying the bind address and bind port can also be possible via environment 567 | variables. Setting PYTEST_HTTPSERVER_HOST and PYTEST_HTTPSERVER_PORT will change the bind host and bind 568 | port, respectively. 569 | 570 | - SSL/TLS support added with using the SSL/TLS support provided by werkzeug. 571 | This is based on the ssl module from the standard library. 572 | 573 | 574 | .. _Release Notes_0.1.1: 575 | 576 | 0.1.1 577 | ===== 578 | 579 | .. _Release Notes_0.1.1_Prelude: 580 | 581 | Prelude 582 | ------- 583 | 584 | Minor fixes in setup.py and build environment. No actual code change in library .py files. 585 | 586 | 587 | .. _Release Notes_0.1: 588 | 589 | 0.1 590 | === 591 | 592 | .. _Release Notes_0.1_Prelude: 593 | 594 | Prelude 595 | ------- 596 | 597 | First release 598 | -------------------------------------------------------------------------------- /CONTRIBUTION.md: -------------------------------------------------------------------------------- 1 | # Contribution guide 2 | 3 | This document describes how to contribute to pytest-httpserver. 4 | 5 | In the case you want to add your own code to the source code, create a pull 6 | request, and it will be reviewed in a few days. Currently all developers working 7 | on this software in their spare time so please be patient. 8 | 9 | This software has only one main purpose: to be useful for the developers and the 10 | users, to help them to achieve what they intend to do with this small library. 11 | It was created by a few people who are doing it in their spare time. This piece 12 | of software is provided for free under the MIT license. 13 | 14 | There's a section in the documentation explaining the design decisions and the main 15 | concepts about the library. Please read it: 16 | https://pytest-httpserver.readthedocs.io/en/latest/background.html 17 | 18 | 19 | ## Rules 20 | 21 | There are a few rules you are kindly asked to accept: 22 | 23 | * Coding style is checked by `pre-commit`. You can run `make precommit` before 24 | proceeding with the PR. To install the pre-commit hooks to your git (so it 25 | will be run for each commit), run `pre-commit install`. 26 | 27 | * Tests should be written for the new code. If there's a complex logic 28 | implemented, it should be tested on different valid and invalid inputs and 29 | scenarios. 30 | 31 | * The software is released under the MIT license, which is simple and liberal. 32 | Due to the size of the project, there are no contribution agreements, but you 33 | are informally advised to accept that license. 34 | 35 | * It may be obvious but your code should make the software better, not worse. 36 | 37 | ## How to start developing 38 | 39 | * The development is arranged around a virtualenv which needs to be created by 40 | the `make dev` command. It will create it in the `.venv` directory. 41 | 42 | * You can let your IDE of your choice to use the `.venv/bin/python` interpreter, 43 | so it will know all the dependencies. 44 | 45 | * running tests on the localhost can be done by issuing `make test`. Note that the 46 | library can be used by many supported interpreters and unless it is absolutely 47 | required, we don't want to drop support. 48 | 49 | * running tests on multiple versions of interpreter locally can be done by 50 | `tox`. Keep in mind that the CI job uses github actions with caching for 51 | effective use, and `tox` is provided for the developers only. 52 | 53 | 54 | ## More technical details 55 | 56 | * Release notes must be written for significant changes. This is done by 57 | the `reno` tool. If you don't write any notes, no problem, it will be written 58 | by someone who merges your PR. 59 | 60 | * Documentation also needs to be written and updated. It means mostly 61 | docstrings, but if the PR changes the code and the way of working 62 | conceptually, the main documentation (located in the doc directory) needs to 63 | be updated and extended. 64 | 65 | * nix files are provided on a best-effort basis. `tox.nix` can be used to run 66 | `tox`, `shell.nix` can be used instead of poetry for development. No tests 67 | have been written for these (yet!), so they may be out of sync occasionally. 68 | 69 | * to release a new version, you can use the `scripts/release.py` script to make 70 | the wheels and sdist, generate the changelog, and tag the commit. This tool 71 | won't upload the artifacts as they need to be checked manually (by installing 72 | the wheel to a new venv, for example). 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Zsolt Cserna 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export POETRY_VIRTUALENVS_IN_PROJECT=true 2 | EXTRAS ?= develop 3 | SPHINXOPTS ?= -n 4 | 5 | # do poetry install only in case if poetry.lock or pyproject.toml where updated and 6 | # we require a rebuilt. 7 | .venv/.st-venv-completed: poetry.lock pyproject.toml 8 | poetry install --verbose --with $(EXTRAS) 9 | touch .venv/.st-venv-completed 10 | 11 | 12 | .PHONY: dev 13 | dev: .venv/.st-venv-completed 14 | 15 | .PHONY: precommit 16 | precommit: dev 17 | poetry run pre-commit run -a 18 | 19 | .PHONY: mypy 20 | mypy: dev 21 | .venv/bin/mypy 22 | 23 | .PHONY: mrproper 24 | mrproper: clean 25 | rm -rf dist 26 | 27 | .PHONY: clean 28 | clean: cov-clean doc-clean 29 | rm -rf .venv *.egg-info build .eggs __pycache__ */__pycache__ .tox 30 | 31 | .PHONY: test 32 | test: dev 33 | .venv/bin/pytest tests -s -vv --release 34 | .venv/bin/pytest tests -s -vv --ssl 35 | 36 | .PHONY: test-pdb 37 | test-pdb: 38 | .venv/bin/pytest tests -s -vv --pdb 39 | 40 | .PHONY: cov 41 | cov: cov-clean 42 | .venv/bin/coverage run -m pytest -vv tests --release 43 | .venv/bin/coverage run -a -m pytest -vv tests --ssl 44 | .venv/bin/coverage xml 45 | 46 | .PHONY: cov-clean 47 | cov-clean: 48 | rm -rf htmlcov coverage.xml .coverage 49 | 50 | .PHONY: doc 51 | doc: dev 52 | .venv/bin/sphinx-build -M html doc doc/_build $(SPHINXOPTS) $(O) 53 | 54 | .PHONY: doc-clean 55 | doc-clean: 56 | rm -rf doc/_build 57 | 58 | .PHONY: doc-clean 59 | doc-preview: 60 | xdg-open doc/_build/html/index.html 61 | 62 | .PHONY: changes 63 | changes: dev 64 | .venv/bin/reno report --output CHANGES.rst --no-show-source 65 | poetry run pre-commit run --files CHANGES.rst || true 66 | poetry run pre-commit run --files CHANGES.rst 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/csernazs/pytest-httpserver/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/csernazs/pytest-httpserver/actions?query=workflow%3Abuild+branch%3Amaster) 2 | [![Documentation Status](https://readthedocs.org/projects/pytest-httpserver/badge/?version=latest)](https://pytest-httpserver.readthedocs.io/en/latest/?badge=latest) 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 4 | [![codecov](https://codecov.io/gh/csernazs/pytest-httpserver/branch/master/graph/badge.svg?token=MX2JXbHqRH)](https://codecov.io/gh/csernazs/pytest-httpserver) 5 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 7 | [![Downloads](https://static.pepy.tech/badge/pytest-httpserver/month)](https://pepy.tech/project/pytest-httpserver) 8 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) 9 | 10 | ## pytest_httpserver 11 | 12 | HTTP server for pytest 13 | 14 | 15 | ### Nutshell 16 | 17 | This library is designed to help to test http clients without contacting the real http server. 18 | In other words, it is a fake http server which is accessible via localhost can be started with 19 | the pre-defined expected http requests and their responses. 20 | 21 | ### Example 22 | 23 | #### Handling a simple GET request 24 | ```python 25 | def test_my_client( 26 | httpserver, 27 | ): # httpserver is a pytest fixture which starts the server 28 | # set up the server to serve /foobar with the json 29 | httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"}) 30 | # check that the request is served 31 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 32 | ``` 33 | 34 | #### Handing a POST request with an expected json body 35 | ```python 36 | def test_json_request( 37 | httpserver, 38 | ): # httpserver is a pytest fixture which starts the server 39 | # set up the server to serve /foobar with the json 40 | httpserver.expect_request( 41 | "/foobar", method="POST", json={"id": 12, "name": "foo"} 42 | ).respond_with_json({"foo": "bar"}) 43 | # check that the request is served 44 | assert requests.post( 45 | httpserver.url_for("/foobar"), json={"id": 12, "name": "foo"} 46 | ).json() == {"foo": "bar"} 47 | ``` 48 | 49 | 50 | You can also use the library without pytest. There's a with statement to ensure that the server is stopped. 51 | 52 | 53 | ```python 54 | with HTTPServer() as httpserver: 55 | # set up the server to serve /foobar with the json 56 | httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"}) 57 | # check that the request is served 58 | print(requests.get(httpserver.url_for("/foobar")).json()) 59 | ``` 60 | 61 | ### Documentation 62 | 63 | Please find the API documentation at https://pytest-httpserver.readthedocs.io/en/latest/. 64 | 65 | ### Features 66 | 67 | You can set up a dozen of expectations for the requests, and also what response should be sent by the server to the client. 68 | 69 | 70 | #### Requests 71 | 72 | There are three different types: 73 | 74 | - **permanent**: this will be always served when there's match for this request, you can make as many HTTP requests as you want 75 | - **oneshot**: this will be served only once when there's a match for this request, you can only make 1 HTTP request 76 | - **ordered**: same as oneshot but the order must be strictly matched to the order of setting up 77 | 78 | You can also fine-tune the expected request. The following can be specified: 79 | 80 | - URI (this is a must) 81 | - HTTP method 82 | - headers 83 | - query string 84 | - data (HTTP body of the request) 85 | - JSON (HTTP body loaded as JSON) 86 | 87 | 88 | #### Responses 89 | 90 | Once you have the expectations for the request set up, you should also define the response you want to send back. 91 | The following is supported currently: 92 | 93 | - respond arbitrary data (string or bytearray) 94 | - respond a json (a python dict converted in-place to json) 95 | - respond a Response object of werkzeug 96 | - use your own function 97 | 98 | Similar to requests, you can fine-tune what response you want to send: 99 | 100 | - HTTP status 101 | - headers 102 | - data 103 | 104 | 105 | #### Behave support 106 | 107 | Using the `BlockingHTTPServer` class, the assertion for a request and the 108 | response can be performed in real order. For more info, see the 109 | [test](tests/test_blocking_httpserver.py), the 110 | [howto](https://pytest-httpserver.readthedocs.io/en/latest/howto.html#running-httpserver-in-blocking-mode) 111 | and the [API 112 | documentation](https://pytest-httpserver.readthedocs.io/en/latest/api.html#blockinghttpserver). 113 | 114 | 115 | ### Missing features 116 | * HTTP/2 117 | * Keepalive 118 | * ~~TLS~~ 119 | 120 | ### Donation 121 | 122 | Currently, this project is based heavily on werkzeug and pytest. 123 | 124 | Werkzeug does all the heavy lifting behind the scenes, parsing HTTP request and 125 | defining Request and Response objects, which are currently transparent in the 126 | API. 127 | 128 | If you wish to donate to werkzeug: https://palletsprojects.com/donate 129 | 130 | 131 | Pytest is the de-facto test library for python. 132 | 133 | If you wish to donate to pytest: https://opencollective.com/pytest 134 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "tests/**" 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: 90% 8 | threshold: 50% 9 | patch: off 10 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = pytest_httpserver 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/_static/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csernazs/pytest-httpserver/8e6d46eddbdba50ba91bbfb9edbdbe52158bf103/doc/_static/.placeholder -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _api-documentation: 3 | 4 | API documentation 5 | ================= 6 | 7 | pytest_httpserver 8 | ----------------- 9 | 10 | .. automodule:: pytest_httpserver 11 | 12 | HTTPServer 13 | ~~~~~~~~~~ 14 | 15 | .. autoclass:: HTTPServer 16 | :members: 17 | :inherited-members: 18 | 19 | RequestHandler 20 | ~~~~~~~~~~~~~~ 21 | 22 | .. autoclass:: RequestHandler 23 | :members: 24 | :inherited-members: 25 | 26 | 27 | RequestMatcher 28 | ~~~~~~~~~~~~~~ 29 | 30 | .. autoclass:: RequestMatcher 31 | :members: 32 | 33 | 34 | BlockingHTTPServer 35 | ~~~~~~~~~~~~~~~~~~ 36 | 37 | .. autoclass:: BlockingHTTPServer 38 | :members: 39 | :inherited-members: 40 | 41 | BlockingRequestHandler 42 | ~~~~~~~~~~~~~~~~~~~~~~ 43 | 44 | .. autoclass:: BlockingRequestHandler 45 | :members: 46 | :inherited-members: 47 | 48 | WaitingSettings 49 | ~~~~~~~~~~~~~~~ 50 | 51 | .. autoclass:: WaitingSettings 52 | :members: 53 | 54 | HeaderValueMatcher 55 | ~~~~~~~~~~~~~~~~~~ 56 | 57 | .. autoclass:: HeaderValueMatcher 58 | :members: 59 | 60 | URIPattern 61 | ~~~~~~~~~~ 62 | 63 | .. autoclass:: URIPattern 64 | :members: 65 | 66 | HTTPServerError 67 | ~~~~~~~~~~~~~~~ 68 | 69 | .. autoclass:: HTTPServerError 70 | :members: 71 | 72 | NoHandlerError 73 | ~~~~~~~~~~~~~~ 74 | 75 | .. autoclass:: NoHandlerError 76 | :members: 77 | 78 | 79 | pytest_httpserver.httpserver 80 | ---------------------------- 81 | This module contains some internal classes which are normally not instantiated 82 | by the user. 83 | 84 | .. automodule:: pytest_httpserver.httpserver 85 | 86 | .. autoclass:: RequestMatcher 87 | :members: 88 | 89 | .. autoclass:: pytest_httpserver.httpserver.HTTPServerBase 90 | :members: 91 | 92 | .. autoclass:: pytest_httpserver.httpserver.Error 93 | :members: 94 | 95 | .. autoclass:: pytest_httpserver.httpserver.NoHandlerError 96 | :members: 97 | 98 | .. autoclass:: pytest_httpserver.httpserver.HTTPServerError 99 | :members: 100 | 101 | .. autoclass:: pytest_httpserver.httpserver.RequestHandlerList 102 | :members: 103 | 104 | 105 | pytest_httpserver.hooks 106 | ----------------------- 107 | 108 | .. automodule:: pytest_httpserver.hooks 109 | 110 | 111 | .. autoclass:: pytest_httpserver.hooks.Chain 112 | :members: 113 | 114 | .. autoclass:: pytest_httpserver.hooks.Delay 115 | :members: 116 | 117 | .. autoclass:: pytest_httpserver.hooks.Garbage 118 | :members: 119 | -------------------------------------------------------------------------------- /doc/background.rst: -------------------------------------------------------------------------------- 1 | .. _background: 2 | 3 | Background 4 | ========== 5 | 6 | This document describes what design decisions were made during the development 7 | of this library. It also describes how the library works in detail. 8 | 9 | This document assumes that you can use the library and have at least limited 10 | knowledge about the source code. If you feel that it is not true for you, you 11 | may want to read the :ref:`tutorial` and :ref:`howto`. 12 | 13 | 14 | API design 15 | ---------- 16 | 17 | The API should be simple for use to simple cases, but also provide great 18 | flexibility for the advanced cases. When increasing flexibility of the API it 19 | should not change the simple API unless it is absolutely required. 20 | 21 | API compatibility is paramount. API breaking is only allowed when it is on par 22 | with the gain of the new functionality. 23 | 24 | Adding new parameters to functions which have default value is not considered a 25 | breaking API change. 26 | 27 | 28 | Simple API 29 | ~~~~~~~~~~ 30 | 31 | API should be kept as simple as possible. It means that describing an expected 32 | request and its response should be trivial for the user. For this reason, the 33 | API is flat: it contains a handful of functions which have many parameters 34 | accepting built-in python types (such as bytes, string, int, etc) in contrast 35 | to more classes and functions with less arguments. 36 | 37 | This API allows to define an expected request and the response which will be 38 | sent back to the client in a single line. This is one of the key features so 39 | using the library is not complicated. 40 | 41 | Example: 42 | 43 | .. literalinclude :: ../tests/examples/test_example_query_params1.py 44 | :language: python 45 | 46 | It is simple in the most simple cases, but once the expectation is more 47 | specific, the line can grow significantly, so here the user is expected to put 48 | the literals into variables: 49 | 50 | .. literalinclude :: ../tests/examples/test_example_query_params2.py 51 | :language: python 52 | 53 | If the user wants something more complex, classes are available for this which 54 | can be instantiated and then specified for the parameters normally accepting 55 | only built-in types. 56 | 57 | The easy case should be made easy, with the possibility of making advanced 58 | things in a bit more complex way. 59 | 60 | Flexible API 61 | ~~~~~~~~~~~~ 62 | 63 | The API should be also made flexible as possible but it should not break the 64 | simple API and not make the simple API complicated. A good example for this is 65 | the `respond_with_handler` method, which accepts a callable object (eg. a 66 | function) which receives the request object and returns the response object. 67 | 68 | The user can implement the required logic there. 69 | 70 | Adding this flexibility however did not cause any change in the simple API, the 71 | simple cases can be still used as before. 72 | 73 | 74 | Higher-level API 75 | ~~~~~~~~~~~~~~~~ 76 | 77 | In the early days of this library, it wanted to support the low-level http 78 | protocol elements: request status, headers, etc to provide full coverage for the 79 | protocol itself. This was made in order to make the most advanced customizations 80 | possible. 81 | 82 | Then the project received a few PRs adding `HeaderValueMatcher` and support for 83 | authorization which relied on the low-level API to add a higher-level API 84 | without breaking it. In the opposite case, adding a low-level API to a 85 | high-level would not be possible. 86 | 87 | Transparency 88 | ~~~~~~~~~~~~ 89 | 90 | The API provided by *pytest-httpserver* is transparent. That means that the 91 | objects (most importantly the `Request` and `Response` objects) defined by 92 | *werkzeug* are visible by the user of *pytest-httpserver*, there is no wrapping 93 | made. This is done by the sake of simplicity. 94 | 95 | As *werkzeug* provides a stable API, there's no need to change this in the 96 | future, however this also limits the library to stick with *werkzeug* in the 97 | long term. Replacing *werkzeug* to something else would break the API due to 98 | this transparency. 99 | 100 | Requirements 101 | ------------ 102 | 103 | This section describes how to work with pytest-httpserver's requirements. 104 | These are the packages used by the library. 105 | 106 | Number of requirements 107 | ~~~~~~~~~~~~~~~~~~~~~~ 108 | 109 | It is required to keep the requirements at minimum. When adding a new library to 110 | the package requirements, research in the following topics should be done: 111 | 112 | * code quality 113 | * activity of the development and maintenance 114 | * number of open issues, and their content 115 | * how many people using that library 116 | * python interpreter versions supported 117 | * amount of API breaking changes 118 | * license 119 | 120 | Sometimes, it is better to have the own implementation instead of having a tiny 121 | library added to the requirements, which may cause compatibility issues. 122 | 123 | 124 | Requirements version restrictions 125 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 126 | 127 | In general, the package requirements should have no version restrictions. For 128 | example, the *werkzeug* library has no restrictions, which means that if a new 129 | version comes out of it, it is assumed that *pytest-httpserver* will be able to 130 | run with it. 131 | 132 | Many people uses this library in an environment having full of other packages 133 | and limiting version here will limit their versions in their requirements also. 134 | For example if there's a software using *werkzeug* `1.0.0` and our requirements 135 | have `<0.9` specified it will make *pytest-httpserver* incompatible with their 136 | software. 137 | 138 | 139 | Requirements testing 140 | ~~~~~~~~~~~~~~~~~~~~ 141 | 142 | Currently it is required to test with only the latest version of the required 143 | packages. However, if there's an API breaking change which affects 144 | *pytest-httpserver*, a decision should be made: 145 | 146 | * apply version restrictions, possibly making *pytest-httpserver* incompatible 147 | with some other software 148 | 149 | * add workaround to *pytest-httpserver* to support both APIs 150 | 151 | 152 | HTTP server 153 | ----------- 154 | 155 | The chosen HTTP server which drives this library is implemented by the *werkzeug* 156 | library. The reason behind this decision is that *werkzeug* is used by Flask, a 157 | very popular web framework and it provides a proven, stable API in the long 158 | term. 159 | 160 | Supported python versions 161 | ------------------------- 162 | 163 | Supporting the latest python versions (such as 3.7 and 3.8 at the time of 164 | writing this), is a must. Supporting the older versions is preferred, following 165 | the state of the officially supported python versions by PSF. 166 | 167 | The library should be tested periodically on the supported versions. 168 | 169 | Dropping support for old python versions is possible if supporting would cause 170 | an issue or require extensive workaround. 171 | 172 | Python support for a given version is also dropped if it is near to the end of 173 | support or when a dependency deprecates it - this is needed to move forward with 174 | the community in order to support the latest versions of the dependencies. 175 | 176 | 177 | Testing and coverage 178 | -------------------- 179 | 180 | It is not required to have 100% test coverage but all possible use-cases should 181 | be covered. Github actions is used to test the library on all the supported 182 | python versions, and tox.ini is provided if local testing is desired. 183 | 184 | When a bug is reported, there should be a test for it, which would re-produce 185 | the error and it should pass with the fix. 186 | 187 | Server starting and stopping 188 | ---------------------------- 189 | 190 | The server is started when the first test is run which uses the httpserver 191 | fixture. It will be running till the end of the session, and new tests will use 192 | the same instance. A cleanup is done between the tests which restores the clean 193 | state (no handlers registered, empty log, etc) to avoid cross-talk. 194 | 195 | The reason behind this is the time required to stop the server. For some reason, 196 | *werkzeug* (the http server used) needs about 1 second to stop itself. Adding this 197 | time to each test is not acceptable in most of the cases. 198 | 199 | Note that it is still compatible with *pytest-xdist* (a popular pytest extension 200 | to run the tests in parallel) as in such case, distinct test sessions will be 201 | run and those will have their own http server instance. 202 | 203 | 204 | Fixture scope 205 | ------------- 206 | 207 | Due to the nature of the http server (it is run only once), it seems to be a 208 | good recommendation to keep the httpserver fixture session scoped, not function 209 | scoped. The problem is that the cleanup which needs to be done between the 210 | tests (as the server is run only once, see above), and that cleanup needs to be 211 | attached to a function scoped fixture. 212 | 213 | HTTP port selection 214 | ------------------- 215 | 216 | In early versions of the library, the user had to specify which port the server 217 | should be bound. This later changed to have an so-called ephemeral port, which 218 | is a random free port number chosen by the kernel. It is good because it 219 | guarantees that it will be available and it allows parallel test runnings for 220 | example. 221 | 222 | In some cases it is not desired (eg if the code being tested has wired-in port 223 | number), in such cases it is still possible to specify the port number. 224 | 225 | Also, the host can be specified which allows to bind on "0.0.0.0" so the server 226 | is accessible from the network in case you want to test a javascript code 227 | running on a different server in a browser. 228 | -------------------------------------------------------------------------------- /doc/changes.rst: -------------------------------------------------------------------------------- 1 | .. _changes: 2 | 3 | .. include:: ../CHANGES.rst 4 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # pytest_httpserver documentation build configuration file, created by 5 | # sphinx-quickstart on Sat Aug 11 08:07:37 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | 21 | import os 22 | import sys 23 | from typing import Dict 24 | 25 | sys.path.insert(0, os.path.abspath("..")) 26 | 27 | import doc.patch 28 | 29 | # -- General configuration ------------------------------------------------ 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | # 33 | # needs_sphinx = '1.0' 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 37 | # ones. 38 | extensions = [ 39 | "sphinx.ext.autodoc", 40 | "sphinx.ext.intersphinx", 41 | "sphinx.ext.autosectionlabel", 42 | ] 43 | 44 | intersphinx_mapping = { 45 | "python": ("https://docs.python.org/3", (None, "python-inv.txt")), 46 | "werkzeug": ("https://werkzeug.palletsprojects.com/en/3.0.x", None), 47 | } 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ["_templates"] 51 | 52 | # The suffix(es) of source filenames. 53 | # You can specify multiple suffix as a list of string: 54 | # 55 | # source_suffix = ['.rst', '.md'] 56 | source_suffix = ".rst" 57 | 58 | # The master toctree document. 59 | master_doc = "index" 60 | 61 | # General information about the project. 62 | project = "pytest_httpserver" 63 | copyright = "2020, Zsolt Cserna" 64 | author = "Zsolt Cserna" 65 | 66 | # The version info for the project you're documenting, acts as replacement for 67 | # |version| and |release|, also used in various other places throughout the 68 | # built documents. 69 | # 70 | # The short X.Y version. 71 | version = "1.1.3" 72 | # The full version, including alpha/beta/rc tags. 73 | release = version 74 | 75 | # The language for content autogenerated by Sphinx. Refer to documentation 76 | # for a list of supported languages. 77 | # 78 | # This is also used if you do content translation via gettext catalogs. 79 | # Usually you set "language" from the command line for these cases. 80 | language = "en" 81 | 82 | # List of patterns, relative to source directory, that match files and 83 | # directories to ignore when looking for source files. 84 | # This patterns also effect to html_static_path and html_extra_path 85 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 86 | 87 | # The name of the Pygments (syntax highlighting) style to use. 88 | pygments_style = "sphinx" 89 | 90 | # If true, `todo` and `todoList` produce output, else they produce nothing. 91 | todo_include_todos = False 92 | 93 | 94 | # -- Options for HTML output ---------------------------------------------- 95 | 96 | # The theme to use for HTML and HTML Help pages. See the documentation for 97 | # a list of builtin themes. 98 | # 99 | html_theme = "sphinx_rtd_theme" 100 | 101 | # Theme options are theme-specific and customize the look and feel of a theme 102 | # further. For a list of options available for each theme, see the 103 | # documentation. 104 | # 105 | # html_theme_options = {} 106 | 107 | html_theme_options = { 108 | "canonical_url": "", 109 | "analytics_id": "", 110 | "logo_only": False, 111 | "display_version": True, 112 | "prev_next_buttons_location": "bottom", 113 | "style_external_links": False, 114 | # Toc options 115 | "collapse_navigation": True, 116 | "sticky_navigation": True, 117 | "navigation_depth": 4, 118 | "includehidden": True, 119 | "titles_only": False, 120 | } 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | html_static_path = ["_static"] 126 | 127 | # Custom sidebar templates, must be a dictionary that maps document names 128 | # to template names. 129 | # 130 | # This is required for the alabaster theme 131 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 132 | html_sidebars = { 133 | "**": [ 134 | "relations.html", # needs 'show_related': True theme option to display 135 | "searchbox.html", 136 | ] 137 | } 138 | 139 | 140 | # -- Options for HTMLHelp output ------------------------------------------ 141 | 142 | # Output file base name for HTML help builder. 143 | htmlhelp_basename = "pytest_httpserverdoc" 144 | 145 | 146 | # -- Options for LaTeX output --------------------------------------------- 147 | 148 | latex_elements: Dict[str, str] = { 149 | # The paper size ('letterpaper' or 'a4paper'). 150 | # 151 | # 'papersize': 'letterpaper', 152 | # The font size ('10pt', '11pt' or '12pt'). 153 | # 154 | # 'pointsize': '10pt', 155 | # Additional stuff for the LaTeX preamble. 156 | # 157 | # 'preamble': '', 158 | # Latex figure (float) alignment 159 | # 160 | # 'figure_align': 'htbp', 161 | } 162 | 163 | # Grouping the document tree into LaTeX files. List of tuples 164 | # (source start file, target name, title, 165 | # author, documentclass [howto, manual, or own class]). 166 | latex_documents = [ 167 | (master_doc, "pytest_httpserver.tex", "pytest\\_httpserver Documentation", "Zsolt Cserna", "manual"), 168 | ] 169 | 170 | 171 | # -- Options for manual page output --------------------------------------- 172 | 173 | # One entry per manual page. List of tuples 174 | # (source start file, name, description, authors, manual section). 175 | man_pages = [(master_doc, "pytest_httpserver", "pytest_httpserver Documentation", [author], 1)] 176 | 177 | 178 | # -- Options for Texinfo output ------------------------------------------- 179 | 180 | # Grouping the document tree into Texinfo files. List of tuples 181 | # (source start file, target name, title, author, 182 | # dir menu entry, description, category) 183 | texinfo_documents = [ 184 | ( 185 | master_doc, 186 | "pytest_httpserver", 187 | "pytest_httpserver Documentation", 188 | author, 189 | "pytest_httpserver", 190 | "One line description of project.", 191 | "Miscellaneous", 192 | ), 193 | ] 194 | -------------------------------------------------------------------------------- /doc/fixtures.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _fixtures: 3 | 4 | Fixtures 5 | ======== 6 | 7 | pytest-httpserver provides the following pytest fixtures. These fixtures can be 8 | overridden the usual name, by defining a fixture with the same name. Make sure 9 | that you are defining the fixture with the same scope as the original one. For 10 | more details, read the related part of the `pytest howto`_. 11 | 12 | httpserver 13 | ---------- 14 | 15 | Scope 16 | function 17 | 18 | Type 19 | :py:class:`pytest_httpserver.HTTPServer` 20 | 21 | 22 | This fixture provides the main functionality for the library. It is a httpserver 23 | instance where you can add your handlers and expectations. It is a function 24 | scoped fixture as the server's state needs to be cleared between the tests. 25 | 26 | 27 | 28 | httpserver_listen_address 29 | ------------------------- 30 | 31 | Scope 32 | session (in 1.0.0 and above, *function* otherwise) 33 | 34 | Type: 35 | ``Tuple[str, int]`` 36 | 37 | Default: 38 | ``("localhost", 0)`` 39 | 40 | This fixture can return the address and port where the server will bind. If port 41 | is given is 0, the server to an ephemeral port, which is an available randomly 42 | selected port. If you run your tests in parallel, this should be used so 43 | multiple servers can be started. 44 | 45 | 46 | httpserver_listen_address 47 | 48 | 49 | httpserver_ssl_context 50 | ---------------------- 51 | Scope 52 | session 53 | 54 | Type: 55 | ``ssl.SSLContext`` 56 | 57 | Default: 58 | ``None`` 59 | 60 | 61 | This fixture should return the ssl context which will be used to run a https 62 | server. For more details please see the `ssl`_ module documentation of the 63 | standard library. 64 | 65 | 66 | make_httpserver 67 | --------------- 68 | Scope 69 | session 70 | 71 | Type: 72 | :py:class:`pytest_httpserver.HTTPServer` 73 | 74 | Default: 75 | A running :py:class:`pytest_httpserver.HTTPServer` instance. 76 | 77 | 78 | This is a factory fixture which creates the instance of the httpserver which 79 | will be used by the ``httpserver`` fixture. By default, it uses the 80 | ``httpserver_listen_address`` and the ``httpserver_ssl_context`` fixtures but 81 | can be overridden to add more customization. 82 | 83 | It yields a running HTTPServer instance and also stops it when it is no longer 84 | needed at the end of the session. If you want to customize this fixture it is 85 | highly recommended to look at its definition in `pytest_plugin.py`_. 86 | 87 | 88 | 89 | .. _pytest_plugin.py: 90 | https://github.com/csernazs/pytest-httpserver/blob/master/pytest_httpserver/pytest_plugin.py 91 | 92 | .. _pytest howto: 93 | https://docs.pytest.org/en/documentation-restructure/how-to/fixture.html#overriding-fixtures-on-various-levels 94 | 95 | .. _ssl: 96 | https://docs.python.org/3/library/ssl.html 97 | -------------------------------------------------------------------------------- /doc/guide.rst: -------------------------------------------------------------------------------- 1 | 2 | User's Guide 3 | ============ 4 | 5 | User's guide has been superseded by the :ref:`tutorial` and the :ref:`howto`. 6 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | 2 | pytest-httpserver 3 | ================= 4 | 5 | pytest-httpserver is a python package which allows you to start a real HTTP server 6 | for your tests. The server can be configured programmatically to how to respond to 7 | requests. 8 | 9 | This project aims to provide an easy to use API to start the server, configure 10 | the request handlers and then shut it down gracefully. All of these without touching 11 | a configuration file or dealing with daemons. 12 | 13 | As the HTTP server is spawned in a different thread and listening on a TCP port, you 14 | can use any HTTP client. This library also helps you migrating to a different HTTP 15 | client library without the need to re-write any test for your client application. 16 | 17 | This library can be used with pytest most conveniently but if you prefer to use 18 | other test frameworks, you can still use it with the context API or by writing a wrapper 19 | for it. 20 | 21 | 22 | Example 23 | ------- 24 | 25 | .. code:: python 26 | 27 | import requests 28 | 29 | 30 | def test_json_client(httpserver: HTTPServer): 31 | httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"}) 32 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 33 | 34 | 35 | For further details, please read the :doc:`guide` or the :doc:`api`. 36 | 37 | .. toctree:: 38 | :maxdepth: 2 39 | 40 | tutorial 41 | howto 42 | fixtures 43 | api 44 | background 45 | changes 46 | upgrade 47 | -------------------------------------------------------------------------------- /doc/patch.py: -------------------------------------------------------------------------------- 1 | # this is required to make sphinx able to find references for classes put inside 2 | # typing.TYPE_CHECKING block 3 | 4 | from ssl import SSLContext 5 | 6 | from werkzeug import Request 7 | from werkzeug import Response 8 | 9 | import pytest_httpserver.blocking_httpserver 10 | import pytest_httpserver.httpserver 11 | 12 | pytest_httpserver.httpserver.SSLContext = SSLContext 13 | pytest_httpserver.blocking_httpserver.SSLContext = SSLContext 14 | 15 | pytest_httpserver.blocking_httpserver.Request = Request 16 | pytest_httpserver.blocking_httpserver.Response = Response 17 | -------------------------------------------------------------------------------- /doc/tutorial.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _tutorial: 3 | 4 | Tutorial 5 | ======== 6 | 7 | If you haven't worked with this library yet, this document is for you. 8 | 9 | Writing your first test 10 | ----------------------- 11 | 12 | With pytest-httpserver, a test looks like this: 13 | 14 | .. code:: python 15 | 16 | import requests 17 | 18 | 19 | def test_json_client(httpserver: HTTPServer): 20 | httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"}) 21 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 22 | 23 | In the first line of the code, we are setting up an expectation. The 24 | expectation contains the http request which is expected to be made: 25 | 26 | .. code:: python 27 | 28 | httpserver.expect_request("/foobar") 29 | 30 | This code tells that the httpserver, which is started automatically and running 31 | on localhost, should accept the request "http://localhost/foobar". Configuring 32 | how to handle this request is then done with the following method: 33 | 34 | .. code:: python 35 | 36 | respond_with_json({"foo": "bar"}) 37 | 38 | This tells that when the request arrives to the *http://localhost/foobar* URL, 39 | it must respond with the provided json. The library accepts here any python 40 | object which is json serializable. Here, a dict is provided. 41 | 42 | .. note:: 43 | 44 | It is important to specify what response to be sent back to the client 45 | otherwise *pytest-httpserver* will error with ``Matching request handler 46 | found but no response defined`` message on an incoming request. 47 | 48 | In the next line, an http request is sent with the *requests* library: 49 | 50 | .. code:: python 51 | 52 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 53 | 54 | 55 | There's no customization (such as mocking) to be made. You don't need to 56 | figure out the port number where the server is running, as there's the 57 | ``url_for()`` method provided to format the URL. 58 | 59 | As you can see there are two different part of the httpserver configuration: 60 | 61 | 1. setting up what kind of request we are expecting 62 | 63 | 2. telling how the request should be handled and which content should 64 | be responded. 65 | 66 | Important note on server port number 67 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 68 | 69 | The test should be run with an unprivileged user. As it is not possible to bind 70 | to the default http port (80), the library binds the server to an available port 71 | which is higher than 1024. In the examples on this page when we are referring to 72 | the url *http://localhost/...* it is assumed that the url contains the http port 73 | also. 74 | 75 | It is advised to use the ``url_for()`` method to construct an URL as it will 76 | always contain the correct port number in the URL. 77 | 78 | If you need the http port as an integer, you can get it by the ``port`` 79 | attribute of the ``httpserver`` object. 80 | 81 | 82 | How to test your http client 83 | ---------------------------- 84 | 85 | .. note:: 86 | 87 | This section describes the various ways of http client testing. If you are 88 | sure that pytest-httpserver is the right library for you, you can skip this 89 | section. 90 | 91 | 92 | You've written your first http client application and you want to write a test 93 | for it. You have the following options: 94 | 95 | 1. Test your application against the production http server 96 | 97 | 2. Mock your http calls, so they won't reach any real server 98 | 99 | 3. Run a fake http server listening on localhost behaving like the real http 100 | server 101 | 102 | pytest-httpserver provides API for the 3rd option: it runs a real http 103 | server on localhost so you can test your client connecting to it. 104 | 105 | However, there's no silver bullet and the possibilities above have their pros 106 | and cons. 107 | 108 | 109 | Test your application against the production http server 110 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 111 | 112 | Pros: 113 | 114 | * It needs almost no change in the source code and you can run the tests with no 115 | issues. 116 | 117 | * Writing tests is simple. 118 | 119 | Cons: 120 | 121 | * The tests will use a real connection to the real server, it will generate 122 | some load on the server, which may be acceptable or not. If the real server is 123 | down or you have some connectivity issue, you can't run tests. 124 | 125 | * If the server has some state, for example, a backend database with user data, 126 | authentication, etc, you have to solve the *shared resource* problem if you want 127 | to allow multiple test runnings on different hosts. For example, if there are 128 | more than one developers and/or testers. 129 | 130 | * Ensuring that there's no crosstalk is very important: if there's some 131 | change made by one instance, it should be invisible to the other. It 132 | should either revert the changes or do it in a separate namespace which 133 | will be cleaned up by some other means such as periodic jobs. Also, the test 134 | should not have inconsistent state behind. 135 | 136 | 137 | Mock your http calls, so they won't reach any real server 138 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 139 | 140 | Pros: 141 | 142 | * It needs almost no change in the source code and you can run the tests with no 143 | issues. 144 | 145 | * There are excellent libraries supporting mocking such as **responses** and 146 | **pytest-vcr**. 147 | 148 | * No need to ensure crosstalk or manage shared resources. 149 | 150 | * Tests work offline. 151 | 152 | Cons: 153 | 154 | * No actual http requests are sent. It needs great effort to mock the 155 | existing behavior of the original library (such as **requests**) and you 156 | need to keep the two libraries in sync. 157 | 158 | * Mocking must support the http client library of your choice. Eg. if you 159 | use **requests** you need to use **responses**. If you are using different 160 | libraries, the complexity raises. 161 | 162 | * At some point, it is not like black-box testing as you need to know the 163 | implementation details of the original code. 164 | 165 | * It is required to set up the expected requests and their responses. If the 166 | server doesn't work like your setup, the code will break when it is run with 167 | the real server. 168 | 169 | 170 | Run a fake http server listening on localhost 171 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 172 | 173 | Pros: 174 | 175 | * Writing tests is simple. 176 | 177 | * No need to ensure crosstalk or manage shared resources. 178 | 179 | * Tests work offline. 180 | 181 | * Actual http requests are sent. There's a real http server running speaking 182 | http protocol so you can test all the special cases you need. You 183 | can customize every http request expectations and their responses 184 | to the end. 185 | 186 | * Testing connectivity issues is possible. 187 | 188 | * There's no mocking, no code injection or class replacement. 189 | 190 | * It is black-box testing as there's no need to know anything about the 191 | original code. 192 | 193 | Cons: 194 | 195 | * Some code changes required in the original source code. The code should 196 | accept the server endpoint (host and port) as a parameter or by some means 197 | of configuration. This endpoint will be set to localhost during the test 198 | running. If it is not possible, you need to tweak name resolution. 199 | 200 | * It is required to set up the expected requests and their responses. If the 201 | server doesn't work like your setup, the code will break when it is run with 202 | the real server. 203 | 204 | * Setting up TLS/SSL requires additional knowledge (cert generation, for 205 | example) 206 | 207 | 208 | Specifying the expectations and constraints 209 | ------------------------------------------- 210 | 211 | In the above code, the most simple case was shown. The library provides many ways 212 | to customize the expectations. 213 | 214 | In the example above, the code expected a request to */foobar* with any method 215 | (such as *GET*, *PUT*, *POST*, *DELETE*). If you want to limit the method to the *GET* 216 | method only, you can specify: 217 | 218 | .. code:: python 219 | 220 | httpserver.expect_request("/foobar", method="GET") 221 | 222 | Similarly, specifying the query parameters is possible: 223 | 224 | .. code:: python 225 | 226 | httpserver.expect_request("/foobar", query_string="user=user1", method="GET") 227 | 228 | This will match the GET request made to the http://localhost/foobar?user=user1 229 | URL. If more constraint is specified to the ``expect_request()`` method, the 230 | expectation will be narrower, eg. it is similar when using logical AND. 231 | 232 | If you want, you can specify the query string as a dictionary so the order 233 | of the key-value pairs does not matter: 234 | 235 | .. code:: python 236 | 237 | httpserver.expect_request( 238 | "/foobar", query_string={"user": "user1", "group": "group1"}, method="GET" 239 | ) 240 | 241 | Similar to query parameters, it is possible to specify constraints for http 242 | headers also. 243 | 244 | For many parameters, you can specify either string or some expression (such 245 | as the dict in the example above). 246 | 247 | For example, specifying a regexp pattern for the URI Is also possible by specifying a 248 | compiled regexp object: 249 | 250 | .. code:: python 251 | 252 | httpserver.expect_request( 253 | re.compile("^/foo"), query_string={"user": "user1", "group": "group1"}, method="GET" 254 | ) 255 | 256 | The above will match every URI starting with "/foo". 257 | 258 | All of these are documented in the :ref:`api-documentation`. 259 | 260 | 261 | Specifying responses 262 | -------------------- 263 | 264 | Once you have set up the expected request, it is required to set up the 265 | response which will be returned to the client. 266 | 267 | In the example we used ``respond_with_json()`` but it is also possible to 268 | respond with an arbitrary content. 269 | 270 | .. code:: python 271 | 272 | respond_with_data("Hello world!", content_type="text/plain") 273 | 274 | In the example above, we are responding a text/plain content. 275 | You can specify the status also: 276 | 277 | .. code:: python 278 | 279 | respond_with_data("Not found", status=404, content_type="text/plain") 280 | 281 | 282 | With this method, it is possible to set the response headers, mime type. 283 | 284 | In some cases you need to create your own Response instance (which is the 285 | Response object from the underlying werkzeug library), so you can respond 286 | with it. This allows more customization, however, in most cases the 287 | respond_with_data is sufficient: 288 | 289 | .. code:: python 290 | 291 | respond_with_response(Response("Hello world!")) 292 | # same as 293 | respond_with_data("Hello world!") 294 | 295 | If you need to produce dynamic content, use the ``respond_with_handler`` 296 | method, which accepts a callable (eg. a python function): 297 | 298 | .. code:: python 299 | 300 | def my_handler(request): 301 | # here, examine the request object 302 | return Response("Hello world!") 303 | 304 | 305 | respond_with_handler(my_handler) 306 | 307 | 308 | Ordered and oneshot expectations 309 | -------------------------------- 310 | 311 | In the above examples, we used ``expect_request()`` method, which registered the 312 | request to be handled. During the test running you can issue requests to 313 | this endpoint as many times as you want, and you will get the same response 314 | (unless you used the ``respond_with_handler()`` method, detailed above). 315 | 316 | There are two other additional limitations which can be used: 317 | 318 | * ordered handling, which specifies the order of the requests 319 | * oneshot handling, which specifies the lifetime of the handlers for only 320 | one request 321 | 322 | Ordered handling 323 | ~~~~~~~~~~~~~~~~ 324 | 325 | The ordered handling specifies the order of the requests. It must be the same 326 | as the order of the registration: 327 | 328 | .. code:: python 329 | 330 | def test_ordered(httpserver: HTTPServer): 331 | httpserver.expect_ordered_request("/foobar").respond_with_data("OK foobar") 332 | httpserver.expect_ordered_request("/foobaz").respond_with_data("OK foobaz") 333 | 334 | requests.get(httpserver.url_for("/foobar")) 335 | requests.get(httpserver.url_for("/foobaz")) 336 | 337 | 338 | The above code passes the test running. The first request matches the first 339 | handler, and the second request matches the second one. 340 | 341 | When making the requests in a reverse order, it will fail: 342 | 343 | .. code:: python 344 | 345 | def test_ordered(httpserver: HTTPServer): 346 | httpserver.expect_ordered_request("/foobar").respond_with_data("OK foobar") 347 | httpserver.expect_ordered_request("/foobaz").respond_with_data("OK foobaz") 348 | 349 | requests.get(httpserver.url_for("/foobaz")) 350 | requests.get(httpserver.url_for("/foobar")) # <- fail? 351 | 352 | If you run the above code you will notice that no test failed. This is 353 | because the http server is running in its own thread, separately from the 354 | client code. It has no way to raise an assertion error in the client thread. 355 | 356 | However, this test checks nothing but runs two subsequent queries and that's it. 357 | Checking the http status code would make it fail: 358 | 359 | .. code:: python 360 | 361 | def test_ordered(httpserver: HTTPServer): 362 | httpserver.expect_ordered_request("/foobar").respond_with_data("OK foobar") 363 | httpserver.expect_ordered_request("/foobaz").respond_with_data("OK foobaz") 364 | 365 | assert requests.get(httpserver.url_for("/foobaz")).status_code == 200 366 | assert requests.get(httpserver.url_for("/foobar")).status_code == 200 # <- fail! 367 | 368 | 369 | For further details about error handling, please read the 370 | :ref:`handling-test-errors` chapter. 371 | 372 | 373 | Oneshot handling 374 | ~~~~~~~~~~~~~~~~ 375 | 376 | Oneshot handling is useful when you want to ensure that the client makes only 377 | one request to the specified URI. Once the request is handled and the response 378 | is sent, the handler is no longer registered and a further call to the same URL 379 | will be erroneous. 380 | 381 | .. code:: python 382 | 383 | def test_oneshot(httpserver: HTTPServer): 384 | httpserver.expect_oneshot_request("/foobar").respond_with_data("OK") 385 | 386 | requests.get(httpserver.url_for("/foobar")) 387 | requests.get(httpserver.url_for("/foobar")) # this will get http status 500 388 | 389 | 390 | If you run the above code you will notice that no test failed. This is 391 | because the http server is running in its own thread, separately from the 392 | client code. It has no way to raise an assertion error in the client thread. 393 | 394 | However, this test checks nothing but runs two subsequent queries and that's it. 395 | Checking the http status code would make it fail: 396 | 397 | .. code:: python 398 | 399 | def test_oneshot(httpserver: HTTPServer): 400 | httpserver.expect_oneshot_request("/foobar").respond_with_data("OK") 401 | 402 | assert requests.get(httpserver.url_for("/foobar")).status_code == 200 403 | assert requests.get(httpserver.url_for("/foobar")).status_code == 200 # fail! 404 | 405 | 406 | For further details about error handling, please read the 407 | :ref:`handling-test-errors` chapter. 408 | 409 | .. _handling-test-errors: 410 | 411 | Handling test errors 412 | ~~~~~~~~~~~~~~~~~~~~ 413 | 414 | If you look at carefully at the test running, you realize that the second 415 | request (and all further requests) will get an http status 500 code, 416 | explaining the issue in the response body. When a properly written http 417 | client gets http status 500, it should raise an exception, which will be 418 | unhandled and in the end the test will be failed. 419 | 420 | In some cases, however, you want to make sure that everything is ok so far, 421 | and raise AssertionError when something is not good. Call the 422 | ``check_assertions()`` method of the httpserver object, and this will look at 423 | the server's internal state (which is running in the other thread) and if 424 | there's something not right (such as the order of the requests not matching, 425 | or there was a non-matching request), it will raise an AssertionError and 426 | your test will properly fail: 427 | 428 | .. code:: python 429 | 430 | def test_ordered_ok(httpserver: HTTPServer): 431 | httpserver.expect_ordered_request("/foobar").respond_with_data("OK foobar") 432 | httpserver.expect_ordered_request("/foobaz").respond_with_data("OK foobaz") 433 | 434 | requests.get(httpserver.url_for("/foobaz")) 435 | requests.get(httpserver.url_for("/foobar")) # gets 500 436 | 437 | httpserver.check_assertions() # this will raise AssertionError and make the test failing 438 | 439 | 440 | The server writes a log about the requests and responses which were 441 | processed. This can be accessed in the `log` attribute of the http server. 442 | This log is a python list with 2-element tuples (request, response). 443 | 444 | 445 | Server lifetime 446 | ~~~~~~~~~~~~~~~ 447 | 448 | Http server is started when the first test uses the `httpserver` fixture, 449 | and it will be running for the rest of the session. The server is not 450 | stopped and started between the tests as it is an expensive operation, it 451 | takes up to 1 second to properly stop the server. 452 | 453 | To avoid crosstalk (eg one test leaving its state behind), the server's 454 | state is cleaned up between test runnings. 455 | 456 | Debugging 457 | ~~~~~~~~~ 458 | 459 | If you having multiple requests for the server, adding the call to 460 | ``check_assertions()`` may to debug as it will make the test failed as 461 | soon as possible. 462 | 463 | .. code:: python 464 | 465 | import requests 466 | 467 | 468 | def test_json_client(httpserver: HTTPServer): 469 | httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"}) 470 | requests.get(httpserver.url_for("/foo")) 471 | requests.get(httpserver.url_for("/bar")) 472 | requests.get(httpserver.url_for("/foobar")) 473 | 474 | httpserver.check_assertions() 475 | 476 | In the above code, the first request (to **/foo**) is not successful (it gets 477 | http status 500), but as the response status is not checked (or any of the 478 | response), and there's no call to ``check_assertions()``, the test continues the 479 | running. It gets through the **/bar** request, which is also not successful 480 | (and gets http status 500 also like the first one), then goes the last request 481 | which is successful (as there's a handler defined for it) 482 | 483 | In the end, when checking the check_assertions() raise the error for the first 484 | request, but it is a bit late: figuring out the request which caused the problem 485 | could be troublesome. Also, it will report the problem for the first request only. 486 | 487 | Adding more call of ``check_assertions()`` will help. 488 | 489 | 490 | .. code:: python 491 | 492 | import requests 493 | 494 | 495 | def test_json_client(httpserver: HTTPServer): 496 | httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"}) 497 | requests.get(httpserver.url_for("/foo")) 498 | httpserver.check_assertions() 499 | 500 | requests.get(httpserver.url_for("/bar")) 501 | httpserver.check_assertions() 502 | 503 | requests.get(httpserver.url_for("/foobar")) 504 | httpserver.check_assertions() 505 | 506 | 507 | In the above code, the test will fail after the first request. 508 | 509 | In case you do not want to fail the test, you can use any of these options: 510 | 511 | * ``assertions`` attribute of the ``httpserver`` object is a list of the 512 | known errors. If it is non-empty, then there was an issue. 513 | 514 | * ``format_matchers()`` method of the ``httpserver`` object returns which 515 | handlers have been registered to the server. In some cases, registering 516 | non-matching handlers causes the problem so printing this string can help 517 | to diagnose the problem. 518 | 519 | 520 | Advanced topics 521 | --------------- 522 | 523 | This is the end of the tutorial, however, not everything is covered here and 524 | this library offers a lot more. 525 | 526 | Further readings: 527 | 528 | * :ref:`api-documentation` 529 | * :ref:`howto` 530 | -------------------------------------------------------------------------------- /doc/upgrade.rst: -------------------------------------------------------------------------------- 1 | .. _upgrade: 2 | 3 | Upgrade guides 4 | ============== 5 | 6 | The following document describes how to upgrade to a given version of the 7 | library which introduces breaking changes. 8 | 9 | Introducing breaking changes 10 | ---------------------------- 11 | When a breaking change is about to be made in the library, an intermediate 12 | release is released which generates deprecation warnings when the functionality 13 | to be removed is used. This does not break any functionality but shows a 14 | warning instead. 15 | 16 | Together with this intermediate release, a new *pre-release* is released to 17 | *pypi*. This release removes the functionality described by the warning, but 18 | *pip* does not install this version unless you specify the *--pre* parameter to 19 | *pip install*. 20 | 21 | Once you made the required changes to make your code compatible with the new 22 | version, you can install the new version by *pip install --pre 23 | pytest-httpserver*. 24 | 25 | After a given time period, a new non-pre release is released, this will be 26 | installed by pip similar to other releases and it will break your code if you 27 | have not made the required changes. If this happens, you can still pin the 28 | version in requirements.txt or other places. Usually specifying the version with 29 | `==` operator fixes the version, but for more details please read the 30 | documentation of the tool you are using in manage dependencies. 31 | 32 | 33 | 1.0.0 34 | ----- 35 | 36 | In pytest-httpserver 1.0.0 the following breaking changes were made. 37 | 38 | * The scope of ``httpserver_listen_address`` fixture changed from **function** to **session** 39 | 40 | In order to make your code compatible with the new version of pytest-httpserver, 41 | you need to specify the `session` scope explicitly. 42 | 43 | Example 44 | ~~~~~~~ 45 | 46 | Old code: 47 | 48 | .. code-block:: python 49 | 50 | import pytest 51 | 52 | 53 | @pytest.fixture 54 | def httpserver_listen_address(): 55 | return ("127.0.0.1", 8888) 56 | 57 | New code: 58 | 59 | .. code-block:: python 60 | 61 | import pytest 62 | 63 | 64 | @pytest.fixture(scope="session") 65 | def httpserver_listen_address(): 66 | return ("127.0.0.1", 8888) 67 | 68 | 69 | As this fixture is now defined with session scope, it will be called only once, 70 | when it is first referenced by a test or by another fixture. 71 | 72 | .. note:: 73 | 74 | There were other, non-breaking changes introduced to 1.0.0. For details, 75 | please read the :ref:`changes`. 76 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!.venv/bin/python3 2 | 3 | import urllib.error 4 | import urllib.request 5 | 6 | from pytest_httpserver import HTTPServer 7 | 8 | server = HTTPServer(port=4000) 9 | server.expect_request("/foobar").respond_with_json({"foo": "bar"}) 10 | server.start() 11 | try: 12 | print(urllib.request.urlopen("http://localhost:4000/foobar?name=John%20Smith&age=123").read()) 13 | except urllib.error.HTTPError as err: 14 | print(err) 15 | 16 | server.stop() 17 | -------------------------------------------------------------------------------- /example_pytest.py: -------------------------------------------------------------------------------- 1 | # Run this code as 'pytest example_pytest.py' 2 | 3 | import pytest 4 | import requests 5 | 6 | from pytest_httpserver import HTTPServer 7 | 8 | 9 | # specify where the server should bind to 10 | # you can return 0 as the port, in this case it will bind to a free (ephemeral) TCP port 11 | @pytest.fixture(scope="session") 12 | def httpserver_listen_address(): 13 | return ("127.0.0.1", 8000) 14 | 15 | 16 | # specify httpserver fixture 17 | def test_oneshot_and_permanent_happy_path1(httpserver: HTTPServer): 18 | # define some request handlers 19 | # more details in the documentation 20 | httpserver.expect_request("/permanent").respond_with_data("OK permanent") 21 | httpserver.expect_oneshot_request("/oneshot1").respond_with_data("OK oneshot1") 22 | httpserver.expect_oneshot_request("/oneshot2").respond_with_data("OK oneshot2") 23 | 24 | # query those handlers with a real HTTP client (requests in this example but could by anything) 25 | # the 'url_for' method formats the final URL, so there's no need to wire-in any ports 26 | assert requests.get(httpserver.url_for("/permanent")).text == "OK permanent" 27 | assert requests.get(httpserver.url_for("/oneshot1")).text == "OK oneshot1" 28 | assert requests.get(httpserver.url_for("/oneshot2")).text == "OK oneshot2" 29 | assert requests.get(httpserver.url_for("/permanent")).text == "OK permanent" 30 | 31 | assert len(httpserver.oneshot_handlers) == 0 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pytest_httpserver" 3 | version = "1.1.3" 4 | description = "pytest-httpserver is a httpserver for pytest" 5 | authors = ["Zsolt Cserna "] 6 | license = "MIT" 7 | readme = "README.md" 8 | documentation = "https://pytest-httpserver.readthedocs.io/en/latest/" 9 | classifiers = [ 10 | "Development Status :: 3 - Alpha", 11 | "Intended Audience :: Developers", 12 | "Operating System :: OS Independent", 13 | "Topic :: Software Development :: Libraries :: Python Modules", 14 | "Framework :: Pytest", 15 | ] 16 | repository = "https://github.com/csernazs/pytest-httpserver" 17 | 18 | include = [ 19 | { path = "tests", format = "sdist" }, 20 | { path = "CHANGES.rst", format = "sdist" }, 21 | { path = "CONTRIBUTION.md", format = "sdist" }, 22 | { path = "example*.py", format = "sdist" }, 23 | { path = "doc", format = "sdist" }, 24 | ] 25 | 26 | [tool.poetry.dependencies] 27 | python = ">=3.9" 28 | Werkzeug = ">= 2.0.0" 29 | 30 | 31 | [tool.poetry.plugins.pytest11] 32 | pytest_httpserver = "pytest_httpserver.pytest_plugin" 33 | 34 | [tool.poetry.urls] 35 | "Bug Tracker" = "https://github.com/csernazs/pytest-httpserver/issues" 36 | 37 | [tool.poetry.group.develop] 38 | optional = true 39 | 40 | [tool.poetry.group.develop.dependencies] 41 | pre-commit = ">=2.20,<5.0" 42 | requests = "*" 43 | Sphinx = ">=5.1.1,<8.0.0" 44 | sphinx-rtd-theme = ">=1,<4" 45 | reno = "*" 46 | types-requests = "*" 47 | pytest = ">=7.1.3,<9.0.0" 48 | pytest-cov = ">=3,<7" 49 | coverage = ">=6.4.4,<8.0.0" 50 | tomli = { version = "*", markers = "python_version < '3.11'"} 51 | black = "*" 52 | ruff = "*" 53 | mypy = "*" 54 | 55 | 56 | [tool.poetry.group.doc] 57 | optional = true 58 | 59 | [tool.poetry.group.doc.dependencies] 60 | Sphinx = ">=5.1.1,<8.0.0" 61 | sphinx-rtd-theme = ">=1,<4" 62 | 63 | 64 | [tool.poetry.group.test] 65 | optional = true 66 | 67 | [tool.poetry.group.test.dependencies] 68 | pytest = "*" 69 | pytest-cov = "*" 70 | coverage = "*" 71 | requests = "*" 72 | types-requests = "*" 73 | pre-commit = "*" 74 | tomli = { version = "*", markers = "python_version < '3.11'"} 75 | mypy = "*" 76 | 77 | [build-system] 78 | requires = ["poetry-core>=1.0.0"] 79 | build-backend = "poetry.core.masonry.api" 80 | 81 | [tool.pytest.ini_options] 82 | markers = [ 83 | "ssl: set up ssl context", 84 | "release: run release tests", 85 | ] 86 | 87 | [tool.mypy] 88 | files = ["pytest_httpserver", "scripts", "tests"] 89 | implicit_reexport = false 90 | 91 | 92 | [tool.black] 93 | line-length = 120 94 | safe = true 95 | 96 | [tool.ruff] 97 | lint.select = ["ALL"] 98 | lint.ignore = [ 99 | "I", 100 | "D", 101 | 102 | "ANN", 103 | "ARG005", 104 | "B011", 105 | "B904", 106 | "C408", 107 | "C901", 108 | "COM812", 109 | "EM101", 110 | "EM103", 111 | "FBT002", 112 | "FIX002", 113 | "INP001", 114 | "PGH003", 115 | "PLR0912", 116 | "PLR0913", 117 | "PLR2004", 118 | "PLW2901", 119 | "PT004", 120 | "PT012", 121 | "PT013", 122 | "PTH118", 123 | "PTH120", 124 | "RET504", 125 | "RET505", 126 | "RET506", 127 | "RUF005", 128 | "S101", 129 | "S113", 130 | "S603", 131 | "S607", 132 | "SIM108", 133 | "T201", 134 | "TD002", 135 | "TD003", 136 | "TRY003", 137 | "UP032", 138 | ] 139 | line-length = 120 140 | target-version = "py39" 141 | exclude = ["doc", "example*.py", "tests/examples/*.py"] 142 | -------------------------------------------------------------------------------- /pytest_httpserver/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is package provides the main API for the pytest_httpserver package. 3 | 4 | """ 5 | 6 | __all__ = [ 7 | "METHOD_ALL", 8 | "URI_DEFAULT", 9 | "BlockingHTTPServer", 10 | "BlockingRequestHandler", 11 | "Error", 12 | "HTTPServer", 13 | "HTTPServerError", 14 | "HeaderValueMatcher", 15 | "NoHandlerError", 16 | "RequestHandler", 17 | "RequestMatcher", 18 | "URIPattern", 19 | "WaitingSettings", 20 | ] 21 | 22 | from .blocking_httpserver import BlockingHTTPServer 23 | from .blocking_httpserver import BlockingRequestHandler 24 | from .httpserver import METHOD_ALL 25 | from .httpserver import URI_DEFAULT 26 | from .httpserver import Error 27 | from .httpserver import HeaderValueMatcher 28 | from .httpserver import HTTPServer 29 | from .httpserver import HTTPServerError 30 | from .httpserver import NoHandlerError 31 | from .httpserver import RequestHandler 32 | from .httpserver import RequestMatcher 33 | from .httpserver import URIPattern 34 | from .httpserver import WaitingSettings 35 | -------------------------------------------------------------------------------- /pytest_httpserver/blocking_httpserver.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from queue import Empty 4 | from queue import Queue 5 | from typing import TYPE_CHECKING 6 | from typing import Any 7 | 8 | from pytest_httpserver.httpserver import METHOD_ALL 9 | from pytest_httpserver.httpserver import UNDEFINED 10 | from pytest_httpserver.httpserver import HeaderValueMatcher 11 | from pytest_httpserver.httpserver import HTTPServerBase 12 | from pytest_httpserver.httpserver import QueryMatcher 13 | from pytest_httpserver.httpserver import RequestHandlerBase 14 | from pytest_httpserver.httpserver import URIPattern 15 | 16 | if TYPE_CHECKING: 17 | from collections.abc import Mapping 18 | from re import Pattern 19 | from ssl import SSLContext 20 | 21 | from werkzeug import Request 22 | from werkzeug import Response 23 | 24 | 25 | class BlockingRequestHandler(RequestHandlerBase): 26 | """ 27 | Provides responding to a request synchronously. 28 | 29 | This class should only be instantiated inside the implementation of the :py:class:`BlockingHTTPServer`. 30 | """ 31 | 32 | def __init__(self): 33 | self.response_queue = Queue() 34 | 35 | def respond_with_response(self, response: Response): 36 | self.response_queue.put_nowait(response) 37 | 38 | 39 | class BlockingHTTPServer(HTTPServerBase): 40 | """ 41 | Server instance which enables synchronous matching for incoming requests. 42 | 43 | :param host: the host or IP where the server will listen 44 | :param port: the TCP port where the server will listen 45 | :param ssl_context: the ssl context object to use for https connections 46 | 47 | :param timeout: waiting time in seconds for matching and responding to an incoming request. 48 | manager 49 | 50 | .. py:attribute:: no_handler_status_code 51 | 52 | Attribute containing the http status code (int) which will be the response 53 | status when no matcher is found for the request. By default, it is set to *500* 54 | but it can be overridden to any valid http status code such as *404* if needed. 55 | """ 56 | 57 | DEFAULT_LISTEN_HOST = "localhost" 58 | DEFAULT_LISTEN_PORT = 0 # Use ephemeral port 59 | 60 | def __init__( 61 | self, 62 | host=DEFAULT_LISTEN_HOST, 63 | port=DEFAULT_LISTEN_PORT, 64 | ssl_context: SSLContext | None = None, 65 | timeout: int = 30, 66 | ): 67 | super().__init__(host, port, ssl_context) 68 | self.timeout = timeout 69 | self.request_queue: Queue[Request] = Queue() 70 | self.request_handlers: dict[Request, Queue[BlockingRequestHandler]] = {} 71 | 72 | def assert_request( 73 | self, 74 | uri: str | URIPattern | Pattern[str], 75 | method: str = METHOD_ALL, 76 | data: str | bytes | None = None, 77 | data_encoding: str = "utf-8", 78 | headers: Mapping[str, str] | None = None, 79 | query_string: None | QueryMatcher | str | bytes | Mapping = None, 80 | header_value_matcher: HeaderValueMatcher | None = None, 81 | json: Any = UNDEFINED, 82 | timeout: int = 30, 83 | ) -> BlockingRequestHandler: 84 | """ 85 | Wait for an incoming request and check whether it matches according to the given parameters. 86 | 87 | If the incoming request matches, a request handler is created and registered, 88 | otherwise assertion error is raised. 89 | The request handler can be used once to respond for the request. 90 | If no response is performed in the period given in the timeout parameter of the constructor 91 | or no request arrives in the `timeout` period, assertion error is raised. 92 | 93 | :param uri: URI of the request. This must be an absolute path starting with ``/``, a 94 | :py:class:`URIPattern` object, or a regular expression compiled by :py:func:`re.compile`. 95 | :param method: HTTP method of the request. If not specified (or `METHOD_ALL` 96 | specified), all HTTP requests will match. 97 | :param data: payload of the HTTP request. This could be a string (utf-8 encoded 98 | by default, see `data_encoding`) or a bytes object. 99 | :param data_encoding: the encoding used for data parameter if data is a string. 100 | :param headers: dictionary of the headers of the request to be matched 101 | :param query_string: the http query string, after ``?``, such as ``username=user``. 102 | If string is specified it will be encoded to bytes with the encode method of 103 | the string. If dict is specified, it will be matched to the ``key=value`` pairs 104 | specified in the request. If multiple values specified for a given key, the first 105 | value will be used. If multiple values needed to be handled, use ``MultiDict`` 106 | object from werkzeug. 107 | :param header_value_matcher: :py:class:`HeaderValueMatcher` that matches values of headers. 108 | :param json: a python object (eg. a dict) whose value will be compared to the request body after it 109 | is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked. 110 | If that's desired, add it to the headers parameter. 111 | :param timeout: waiting time in seconds for an incoming request. 112 | 113 | :return: Created and registered :py:class:`BlockingRequestHandler`. 114 | 115 | Parameters `json` and `data` are mutually exclusive. 116 | """ 117 | 118 | matcher = self.create_matcher( 119 | uri, 120 | method=method.upper(), 121 | data=data, 122 | data_encoding=data_encoding, 123 | headers=headers, 124 | query_string=query_string, 125 | header_value_matcher=header_value_matcher, 126 | json=json, 127 | ) 128 | 129 | try: 130 | request = self.request_queue.get(timeout=timeout) 131 | except Empty: 132 | raise AssertionError(f"Waiting for request {matcher} timed out") # noqa: EM102 133 | 134 | diff = matcher.difference(request) 135 | 136 | request_handler = BlockingRequestHandler() 137 | 138 | self.request_handlers[request].put_nowait(request_handler) 139 | 140 | if diff: 141 | request_handler.respond_with_response(self.respond_nohandler(request)) 142 | raise AssertionError(f"Request {matcher} does not match: {diff}") # noqa: EM102 143 | 144 | return request_handler 145 | 146 | def dispatch(self, request: Request) -> Response: 147 | """ 148 | Dispatch a request for synchronous matching. 149 | 150 | This method queues the request for matching and waits for the request handler. 151 | If there was no request handler, error is responded, 152 | otherwise it waits for the response of request handler. 153 | If no response arrives, assertion error is raised, otherwise the response is returned. 154 | 155 | :param request: the request object from the werkzeug library. 156 | :return: the response object what the handler responded, or a response which contains the error. 157 | """ 158 | 159 | self.request_handlers[request] = Queue() 160 | try: 161 | self.request_queue.put_nowait(request) 162 | 163 | try: 164 | request_handler = self.request_handlers[request].get(timeout=self.timeout) 165 | except Empty: 166 | return self.respond_nohandler(request) 167 | 168 | try: 169 | return request_handler.response_queue.get(timeout=self.timeout) 170 | except Empty: 171 | assertion = AssertionError(f"No response for request: {request}") 172 | self.add_assertion(assertion) 173 | raise assertion 174 | finally: 175 | del self.request_handlers[request] 176 | -------------------------------------------------------------------------------- /pytest_httpserver/hooks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Hooks for pytest-httpserver 3 | """ 4 | 5 | import os 6 | import time 7 | from typing import Callable 8 | 9 | from werkzeug import Request 10 | from werkzeug import Response 11 | 12 | 13 | class Chain: 14 | """ 15 | Combine multiple hooks into one callable object 16 | 17 | Hooks specified will be called one by one. 18 | 19 | Each hook will receive the response object made by the previous hook, 20 | similar to reduce. 21 | """ 22 | 23 | def __init__(self, *args: Callable[[Request, Response], Response]): 24 | """ 25 | :param *args: callable objects specified in the same order they should 26 | be called. 27 | """ 28 | self._hooks = args 29 | 30 | def __call__(self, request: Request, response: Response) -> Response: 31 | """ 32 | Calls the callable object one by one. The second and further callable 33 | objects receive the response returned by the previous one, while the 34 | first one receives the original response object. 35 | """ 36 | for hook in self._hooks: 37 | response = hook(request, response) 38 | return response 39 | 40 | 41 | class Delay: 42 | """ 43 | Delays returning the response 44 | """ 45 | 46 | def __init__(self, seconds: float): 47 | """ 48 | :param seconds: seconds to sleep before returning the response 49 | """ 50 | self._seconds = seconds 51 | 52 | def _sleep(self): 53 | """ 54 | Sleeps for the seconds specified in the constructor 55 | """ 56 | time.sleep(self._seconds) 57 | 58 | def __call__(self, _request: Request, response: Response) -> Response: 59 | """ 60 | Delays returning the response object for the time specified in the 61 | constructor. Returns the original response unmodified. 62 | """ 63 | self._sleep() 64 | return response 65 | 66 | 67 | class Garbage: 68 | def __init__(self, prefix_size: int = 0, suffix_size: int = 0): 69 | """ 70 | Adds random bytes to the beginning or to the end of the response data. 71 | 72 | :param prefix_size: amount of random bytes to be added to the beginning 73 | of the response data 74 | 75 | :param suffix_size: amount of random bytes to be added to the end 76 | of the response data 77 | 78 | """ 79 | assert prefix_size >= 0, "prefix_size should be positive integer" 80 | assert suffix_size >= 0, "suffix_size should be positive integer" 81 | self._prefix_size = prefix_size 82 | self._suffix_size = suffix_size 83 | 84 | def _get_garbage_bytes(self, size: int) -> bytes: 85 | """ 86 | Returns the specified amount of random bytes. 87 | 88 | :param size: amount of bytes to return 89 | """ 90 | return os.urandom(size) 91 | 92 | def __call__(self, _request: Request, response: Response) -> Response: 93 | """ 94 | Adds random bytes to the beginning or to the end of the response data. 95 | 96 | New random bytes will be generated for every call. 97 | 98 | Returns the modified response object. 99 | """ 100 | prefix = self._get_garbage_bytes(self._prefix_size) 101 | suffix = self._get_garbage_bytes(self._suffix_size) 102 | response.set_data(prefix + response.get_data() + suffix) 103 | return response 104 | -------------------------------------------------------------------------------- /pytest_httpserver/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csernazs/pytest-httpserver/8e6d46eddbdba50ba91bbfb9edbdbe52158bf103/pytest_httpserver/py.typed -------------------------------------------------------------------------------- /pytest_httpserver/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from .httpserver import HTTPServer 6 | 7 | 8 | class Plugin: 9 | SERVER = None 10 | 11 | 12 | class PluginHTTPServer(HTTPServer): 13 | def start(self): 14 | super().start() 15 | Plugin.SERVER = self 16 | 17 | def stop(self): 18 | super().stop() 19 | Plugin.SERVER = None 20 | 21 | 22 | def get_httpserver_listen_address(): 23 | listen_host = os.environ.get("PYTEST_HTTPSERVER_HOST") 24 | listen_port = os.environ.get("PYTEST_HTTPSERVER_PORT") 25 | if listen_port: 26 | listen_port = int(listen_port) 27 | 28 | return listen_host, listen_port 29 | 30 | 31 | @pytest.fixture(scope="session") 32 | def httpserver_listen_address(): 33 | return get_httpserver_listen_address() 34 | 35 | 36 | @pytest.fixture(scope="session") 37 | def httpserver_ssl_context(): 38 | return None 39 | 40 | 41 | @pytest.fixture(scope="session") 42 | def make_httpserver(httpserver_listen_address, httpserver_ssl_context): 43 | host, port = httpserver_listen_address 44 | if not host: 45 | host = HTTPServer.DEFAULT_LISTEN_HOST 46 | if not port: 47 | port = HTTPServer.DEFAULT_LISTEN_PORT 48 | 49 | server = HTTPServer(host=host, port=port, ssl_context=httpserver_ssl_context) 50 | server.start() 51 | yield server 52 | server.clear() 53 | if server.is_running(): 54 | server.stop() 55 | 56 | 57 | def pytest_sessionfinish(session, exitstatus): # noqa: ARG001 58 | if Plugin.SERVER is not None: 59 | Plugin.SERVER.clear() 60 | if Plugin.SERVER.is_running(): 61 | Plugin.SERVER.stop() 62 | 63 | 64 | @pytest.fixture 65 | def httpserver(make_httpserver): 66 | server = make_httpserver 67 | server.clear() 68 | return server 69 | 70 | 71 | @pytest.fixture(scope="session") 72 | def make_httpserver_ipv4(httpserver_ssl_context): 73 | server = HTTPServer(host="127.0.0.1", port=0, ssl_context=httpserver_ssl_context) 74 | server.start() 75 | yield server 76 | server.clear() 77 | if server.is_running(): 78 | server.stop() 79 | 80 | 81 | @pytest.fixture 82 | def httpserver_ipv4(make_httpserver_ipv4): 83 | server = make_httpserver_ipv4 84 | server.clear() 85 | return server 86 | 87 | 88 | @pytest.fixture(scope="session") 89 | def make_httpserver_ipv6(httpserver_ssl_context): 90 | server = HTTPServer(host="::1", port=0, ssl_context=httpserver_ssl_context) 91 | server.start() 92 | yield server 93 | server.clear() 94 | if server.is_running(): 95 | server.stop() 96 | 97 | 98 | @pytest.fixture 99 | def httpserver_ipv6(make_httpserver_ipv6): 100 | server = make_httpserver_ipv6 101 | server.clear() 102 | return server 103 | -------------------------------------------------------------------------------- /release_checklist.md: -------------------------------------------------------------------------------- 1 | 2 | Sounds like a python script for me, but here's the manual checklist: 3 | 4 | - [ ] check the latest commits on master 5 | - [ ] every major change reflected in the release notes 6 | - [ ] CHANGES.rst is generated by `make changes`: check formatting 7 | - [ ] check the latest doc build at rtd.org 8 | - [ ] check that master is green at travis 9 | - [ ] version bump (sphinx/conf.py, setup.py) 10 | - [ ] tag the HEAD 11 | - [ ] generate documentation, check version at release notes 12 | - [ ] build the dist: setup.py sdist bdist_wheel 13 | - [ ] install package in a local venv 14 | - [ ] run the examples in this venv 15 | - [ ] CHANGES.rst is generated by `make changes`: commit 16 | - [ ] push to github (version bump, tags) 17 | - [ ] upload dist/* to pypi 18 | -------------------------------------------------------------------------------- /releasenotes/notes/additional-type-hints-improvements-b595896ca71b3537.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Additional type hints improvements to make the library more mypy compliant. 5 | Imports in `__init__.py` have been updated to indicate that this is a 6 | namespace package. 7 | -------------------------------------------------------------------------------- /releasenotes/notes/blocking-httpserver-3b58e2a8464b4d97.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Add a new way of running tests with the blocking mode. In this mode, the 5 | http server is synchronized to the main thread and the client code is run in 6 | a separate thread. 7 | -------------------------------------------------------------------------------- /releasenotes/notes/build-minor-fixes-32955967c5948adc.yaml: -------------------------------------------------------------------------------- 1 | prelude: > 2 | Minor fixes in setup.py and build environment. No actual code change in library .py files. 3 | -------------------------------------------------------------------------------- /releasenotes/notes/daemon-thread-dc78e80bd74289dc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Run server threads with daemon flag, preventing shutdown issues. 5 | `#411 `_ 6 | -------------------------------------------------------------------------------- /releasenotes/notes/deprecation-warnings-for-1.0.0-b2b3909e5ad97594.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | deprecations: 3 | - | 4 | Deprecation warnings were added to prepare changes to 1.0.0. More details: 5 | https://pytest-httpserver.readthedocs.io/en/latest/upgrade.html 6 | -------------------------------------------------------------------------------- /releasenotes/notes/environment-variables-8717eb8f545a8d3d.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | When using pytest plugin, specifying the bind address and bind port can also be possible via environment 5 | variables. Setting PYTEST_HTTPSERVER_HOST and PYTEST_HTTPSERVER_PORT will change the bind host and bind 6 | port, respectively. 7 | -------------------------------------------------------------------------------- /releasenotes/notes/ephemeral-port-support-adea94cb8f6088c6.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | features: 4 | - | 5 | Support ephemeral port. This can be used by specify 0 as the port number 6 | to the HTTPServer instance. In such case, an unused port will be picked up 7 | and the server will start listening on that port. Querying the port attribute 8 | after server start reveals the real port where the server is actually listening. 9 | 10 | upgrade: 11 | - | 12 | The default port has been changed to 0, which results that the server will be staring 13 | on an ephemeral port. 14 | -------------------------------------------------------------------------------- /releasenotes/notes/fix-16-query-string-c989bee73733a325.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Fixed issue \#16 by converting string object passed as query_string 5 | to bytes which is the type of the query string in werkzeug, and also allowing 6 | bytes as the parameter. 7 | -------------------------------------------------------------------------------- /releasenotes/notes/fix-log-leak-ee94a826d75173f1.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Fixed an issue related to the leak of httpserver state between the tests 5 | when httpserver is destructed before the other fixtures. `#352 `_ 6 | -------------------------------------------------------------------------------- /releasenotes/notes/fix-packaging-include-673c0b51ebdcd9a2.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Packaging of sdist and the wheel fixed by adding the extra files only to the sdist and not to the wheel. 5 | -------------------------------------------------------------------------------- /releasenotes/notes/fix-test_verify_assert_msg-af37678f187bb8da.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Fix pytest-httpserver's own tests related to log querying. No functional 5 | changes in pytest-httpserver code itself. `#345 `_ 6 | -------------------------------------------------------------------------------- /releasenotes/notes/fix-x509-test-assets-8a1b220d085a504a.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Fix the tests assets created for SSL/TLS tests by extending their expiration time. Also 5 | update the Makefile which can be used to update these assets. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/flake8-version-update-d70b88d9a15a723f.yaml: -------------------------------------------------------------------------------- 1 | other: 2 | - | 3 | Version of flake8 library updated to require 4.0.0+ at minimum. This is 4 | required to make flake8 working on recent python versions. 5 | -------------------------------------------------------------------------------- /releasenotes/notes/header-value-matcher-5b32c6640aef71d8.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Make it possible to intelligently compare headers. To accomplish that 5 | HeaderValueMatcher was added. It already contains logic to compare 6 | unknown headers and authorization headers. Patch by Roman Inflianskas. 7 | -------------------------------------------------------------------------------- /releasenotes/notes/headervaluematcher-type-hinting-fix-f19e7a4d48b0a1d8.yaml: -------------------------------------------------------------------------------- 1 | fixes: 2 | - | 3 | Type hinting for header_value_matcher has been fixed. From now, specifying a 4 | callable as ``Callable[[str, Optional[str], str], bool]`` will be accepted 5 | also. Providing a ``HeaderValueMatcher`` object will be also accepted as 6 | before, as it provides the same callable signature. 7 | -------------------------------------------------------------------------------- /releasenotes/notes/hooks-306915ded3b2771f.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Hooks API 5 | -------------------------------------------------------------------------------- /releasenotes/notes/http-methods-are-case-insensitive-c2a1d49f9809f263.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | HTTP methods are case insensitive. The HTTP method specified is converted to 5 | uppercase in the library. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/httpserver-listen-address-fixture-87f18b2cdbf47532.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | features: 4 | - | 5 | Add httpserver_listen_address fixture which is used to set up the bind address and port 6 | of the server. Setting bind address and port is possible by overriding this fixture. 7 | -------------------------------------------------------------------------------- /releasenotes/notes/improved-error-reporting-in-custom-request-handlers-df479afb8eae03d1.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Improved error handling of custom request handlers. Request handlers added 5 | with ``respond_with_handler`` now can use the ``assert`` statement. Those 6 | errors will be reported when a further ``check_assertions()`` call is made. 7 | Also, unhandled exceptions raised in the request handlers can be re-raised 8 | by calling the new ``check_handler_errors()`` method. A new method called 9 | ``check()`` has been added which calls these two in sequence. 10 | -------------------------------------------------------------------------------- /releasenotes/notes/initial-ef17767ee807ab89.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | prelude: > 3 | First release 4 | -------------------------------------------------------------------------------- /releasenotes/notes/json-matcher-1d030e3b4f9b66bd.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | It is now possible to specify a JSON-serializable python value (such as 5 | dict, list, etc) and match the request to it as JSON. The request's body 6 | is loaded as JSON and it will be compared to the expected value. 7 | -------------------------------------------------------------------------------- /releasenotes/notes/log-querying-683219f3587d2139.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | New methods added to query for matching requests in the log. 5 | -------------------------------------------------------------------------------- /releasenotes/notes/minor-fixes-496615d00d2b3e44.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | prelude: > 3 | Minor fixes in setup.py and build environment. No actual code change in library .py files. 4 | -------------------------------------------------------------------------------- /releasenotes/notes/mypy-fix-for-headervaluematcher-fba16bfa9dc3e0e4.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Fixed type hinting of ``HeaderValueMatcher.DEFAULT_MATCHERS``, which did 5 | not allow modifications, however it is explicitly allowed in the documentation. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/new-expect-method-4f8d071c78c9884b.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Add a new ``expect`` method to the ``HTTPServer`` object which allows 5 | developers to provide their own request matcher object. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/new-fixture-for-httpserver-making-98afa235d3283831.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Added a new session scoped fixture ``make_httpserver`` which creates the 5 | object for the ``httpserver`` fixture. It can be overridden to add further 6 | customizations and it must yield a ``HTTPServer`` object - see 7 | ``pytest_plugin.py`` for an implementation. As this fixture is session 8 | scoped, it will be called only once when the first test using httpserver is 9 | started. This addition also deprecates the use of ``PluginHTTPServer`` which was 10 | used in earlier versions as a way to customize server object creation. 11 | ``PluginHTTPServer`` can still be used but it may be subject to deprecation 12 | in the future. 13 | -------------------------------------------------------------------------------- /releasenotes/notes/new-fixture-for-ssl-context-054da072a46e9e62.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Added a new session scoped fixture ``httpserver_ssl_context`` which provides 5 | the SSL context for the server. By default it returns ``None``, so SSL is 6 | not enabled, but can be overridden to return a valid ``ssl.SSLContext`` 7 | object which will be used for SSL connections. See test_ssl.py for example. 8 | -------------------------------------------------------------------------------- /releasenotes/notes/no-handler-status-code-380fa02ebe9b2721.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | The http response code sent when no handler is found for the 5 | request can be changed. It is set to 500 by default. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/nohandler-response-details-2af020f4763fcea8.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | When there's no handler for the request, add more details to the response 5 | sent by the server about the request to help debugging. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/poetry-5c16af5ed108ba9c.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | other: 3 | - | 4 | Package deployment and CI has been migrated to poetry. poetry.lock will be kept 5 | up to date. Make target "quick-test" renamed to "test". Also, minor adjustments 6 | were made regarding documentation generation. Make targets should be identical. 7 | Build results like sdist, and wheel are almost identical to the one which was 8 | made by setuptools. 9 | -------------------------------------------------------------------------------- /releasenotes/notes/pytest-httpserver-listen-address-scope-change-0c1aa457e20cae83.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | upgrade: 3 | - | 4 | **Breaking change**: The scope of ``httpserver_listen_address`` fixture changed from **function** 5 | to **session**. This is a requirement to implement the other features listed 6 | in this release. See the `upgrade guide 7 | `_ for the 8 | details. 9 | -------------------------------------------------------------------------------- /releasenotes/notes/python-34-35-deprecation-a4a3b57d1f2875d7.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | deprecations: 3 | - | 4 | Python 3.4 and 3.5 versions have been deprecated in order to support type 5 | hints in the source code. Users using 3.5 and earlier releases encouraged 6 | to upgrade to later versions. Please node that 3.5 reached EOL in September 7 | of 2020 and no longer receives security fixes. 8 | -------------------------------------------------------------------------------- /releasenotes/notes/python-37-deprecation-72029b78e91d6b26.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | deprecations: 3 | - | 4 | Python versions earlier than 3.8 have been deprecated in order to support 5 | the latest werkzeug. Users using 3.7 or earlier python may use 6 | pytest-httpserver with earlier werkzeug versions but tests are no longer run 7 | for these python versions. 8 | -------------------------------------------------------------------------------- /releasenotes/notes/python-38-deprecation-48b0c8be245f63d1.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | deprecations: 3 | - | 4 | Python versions earlier than 3.9 have been deprecated in order to make the 5 | code more type safe. Python 3.8 has reached EOL on 2024-10-07. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/python-classifier-fix-bfe43601d16f27d8.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Python version classifier updated in pyproject.toml (which updates pypi also) 5 | -------------------------------------------------------------------------------- /releasenotes/notes/query-matcher-751db32b2ac1fc74.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Besides bytes and string, dict and MultiDict objects can be specified as query_string. 5 | When these objects are used, the query string gets parsed into a dict (or MultiDict), 6 | and comparison is made accordingly. This enables the developer to ignore the order of 7 | the keys in the query_string when expecting a request. 8 | -------------------------------------------------------------------------------- /releasenotes/notes/re-release-107-23f0fd429612b470.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Version 1.0.7 has been released with incorrect dependencies. This is fixed now. 5 | -------------------------------------------------------------------------------- /releasenotes/notes/release-tag-fix-8b2dfc26a24598c3.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Fix release tagging. 0.3.2 was released in a mistake by tagging 3.0.2 to the branch. 5 | -------------------------------------------------------------------------------- /releasenotes/notes/requesthandler-repr-09f342f19f6250bc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Add ``__repr__`` to ``RequestHandler`` object so when it is compared (eg. with 5 | the ``log`` attribute of the server) it will show the matcher parameters. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/same-as-1.0.0rc1-6356c8b1c488e3cd.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | prelude: > 3 | Functionally the same as 1.0.0rc1. For the list of changes between 0.3.8 and 1.0.0 see the changelist for 1.0.0rc1. 4 | -------------------------------------------------------------------------------- /releasenotes/notes/sdist-new-files-d99db1317673be9c.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | other: 3 | - | 4 | Add more files to source distribution (sdist). It now contains tests, 5 | assets, examples and other files. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/setup-py-remove-pytest-runner-dd60d3f20ed45f1c.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | other: 3 | - | 4 | Removed pytest-runner from setup.py as it is deprecated and makes packaging inconvenient 5 | as it needs to be installed before running setup.py. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/ssl-support-13321dd9d636af34.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | SSL/TLS support added with using the SSL/TLS support provided by werkzeug. 5 | This is based on the ssl module from the standard library. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/threading-support-28c89686025e2184.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Threading support to serve requests in parallel 5 | -------------------------------------------------------------------------------- /releasenotes/notes/type-hints-improvement-02e0efd620644440.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Type hints updated to conform to 'mypy' type checking tool. 5 | Also, py.typed file is added as package data according to PEP 561. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/unify-expect-request-functions-bd877c586b62a294.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Unify request functions of the HTTPServer class to make the API more straightforward to use. 5 | upgrade: 6 | - | 7 | The following methods of HTTPServer have been changed in a backward-incompatible way: 8 | * :py:meth:`pytest_httpserver.HTTPServer.expect_request` becomes a general function accepting handler_type parameter so it can create any kind of request handlers 9 | * :py:meth:`pytest_httpserver.HTTPServer.expect_oneshot_request` no longer accepts the ordered parameter, and it creates an unordered oneshot request handler 10 | * :py:meth:`pytest_httpserver.HTTPServer.expect_ordered_request` is a new method creating an ordered request handler 11 | -------------------------------------------------------------------------------- /releasenotes/notes/uri-matching-dba6660cb0689402.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Extend URI matching by allowing to specify URIPattern object or a compiled 5 | regular expression, which will be matched against the URI. URIPattern class 6 | is defined as abstract in the library so the user need to implement a new 7 | class based on it. 8 | -------------------------------------------------------------------------------- /releasenotes/notes/use-ruff-for-linting-a0f446e9df39c719.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | other: 3 | - | 4 | Use ruff for linting. It includes some source code changes which should not 5 | introduce functional changes, or API changes. 6 | -------------------------------------------------------------------------------- /releasenotes/notes/werkzeug-header-type-follow-up-74a80dd03e6ca6db.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | upgrade: 3 | - With werkzeug 2.3.x the headers type has been updated to not allow integers 4 | as header values. This restriction followed up in pytest-httpserver. 5 | -------------------------------------------------------------------------------- /releasenotes/notes/werkzeug-parse_authorization_header-deprecation-fix-8264966b70fddc6d.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Fix Werkzeug deprecation warning about ``parse_authorization_header`` call. 5 | Replace ``parse_authorization_header`` with ``Authorization.from_header`` as 6 | suggested. This fix should not introduce any functional change for the 7 | users. 8 | -------------------------------------------------------------------------------- /releasenotes/notes/werkzeug-urls-url_decode-deprecation-fix-56fe3c183b53f83b.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fixes: 3 | - | 4 | Fix Werkzeug deprecation warning about ``werkzeug.urls.url_decode`` call. This 5 | call has been changed to ``urllib.parse.parse_qsl`` in the implementation. 6 | This fix should not introduce any functional change for the users. 7 | -------------------------------------------------------------------------------- /scripts/release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import subprocess 5 | import sys 6 | from collections.abc import Iterable 7 | from pathlib import Path 8 | from shutil import which 9 | 10 | 11 | class UsageError(Exception): 12 | pass 13 | 14 | 15 | def parse_version() -> str: 16 | output = subprocess.check_output(["poetry", "version", "--short"], encoding="utf-8") 17 | version = output.strip() 18 | 19 | return version 20 | 21 | 22 | def bump_version(path: Path, prefix_list: Iterable[str], current_version: str, new_version: str): 23 | prefixes = tuple(prefix_list) 24 | lines = [] 25 | for line in path.open(): 26 | if line.startswith(prefixes): 27 | line = line.replace(current_version, new_version) 28 | lines.append(line) 29 | 30 | path.write_text("".join(lines)) 31 | 32 | 33 | def git(*args): 34 | return subprocess.check_call(["git"] + list(args)) 35 | 36 | 37 | def make(*args): 38 | return subprocess.check_call(["make"] + list(args)) 39 | 40 | 41 | def check_changelog(): 42 | old_changelog = Path("CHANGES.rst").read_text() 43 | make("changes") 44 | new_changelog = Path("CHANGES.rst").read_text() 45 | if old_changelog == new_changelog: 46 | raise UsageError("No new changelog entries") 47 | 48 | 49 | def check_environment(): 50 | for binary in ("git", "make", "poetry"): 51 | if not which(binary): 52 | raise UsageError("No such binary: {}".format(binary)) 53 | 54 | 55 | def main() -> None: 56 | parser = argparse.ArgumentParser() 57 | parser.add_argument("new_version", help="Version to release") 58 | 59 | args = parser.parse_args() 60 | new_version: str = args.new_version 61 | 62 | current_version = parse_version() 63 | if current_version is None: 64 | raise UsageError("Unable to parse version") 65 | 66 | print(f"Current version: {current_version}") 67 | 68 | if current_version == new_version: 69 | raise UsageError("Current version is the same as new version") 70 | 71 | check_changelog() 72 | 73 | bump_version(Path("doc/conf.py"), ["version"], current_version, new_version) 74 | subprocess.check_call(["poetry", "version", new_version]) 75 | 76 | git("add", "pyproject.toml", "doc/conf.py") 77 | git("commit", "-m", "Version bump to {}".format(new_version)) 78 | git("tag", new_version) 79 | make("changes") 80 | git("add", "CHANGES.rst") 81 | git("commit", "-m", "CHANGES.rst: add release notes for {}".format(new_version)) 82 | git("tag", "-f", new_version) 83 | 84 | 85 | if __name__ == "__main__": 86 | try: 87 | main() 88 | except UsageError as err: 89 | print(f"ERROR: {err}", file=sys.stderr) 90 | sys.exit(1) 91 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } }: 2 | 3 | let 4 | unstable = import { config = { allowUnfree = true; }; }; 5 | in 6 | pkgs.mkShell { 7 | buildInputs = with pkgs; [ 8 | virtualenv 9 | 10 | python3Packages.tox 11 | python3Packages.poetry-core 12 | 13 | pre-commit 14 | python3Packages.requests 15 | python3Packages.sphinx 16 | python3Packages.sphinx-rtd-theme 17 | reno 18 | python3Packages.mypy 19 | python3Packages.types-requests 20 | python3Packages.pytest 21 | python3Packages.pytest-cov 22 | python3Packages.coverage 23 | python3Packages.ipdb 24 | python3Packages.types-toml 25 | python3Packages.toml 26 | python3Packages.black 27 | 28 | 29 | bashInteractive 30 | ]; 31 | } 32 | -------------------------------------------------------------------------------- /tests/assets/Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | all: server.crt 4 | 5 | 6 | rootCA.key: 7 | openssl genrsa -out rootCA.key 2048 8 | 9 | rootCA.crt: rootCA.key rootCA.cnf 10 | openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 11083 -out rootCA.crt -config rootCA.cnf 11 | 12 | server.csr server.key: server.cnf 13 | openssl req -new -sha256 -nodes -out server.csr -newkey rsa:2048 -keyout server.key -config server.cnf 14 | 15 | server.crt: server.csr rootCA.crt rootCA.key v3.ext 16 | openssl x509 -req -in server.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial -out server.crt -days 11083 -sha256 -extfile v3.ext 17 | 18 | clean: 19 | rm -f rootCA.key rootCA.crt server.csr server.key server.crt 20 | -------------------------------------------------------------------------------- /tests/assets/README: -------------------------------------------------------------------------------- 1 | 2 | !!! WARNING !!! 3 | 4 | This directory contains a certificate and a root CA for testing. 5 | Never use these certs in production as these are (including the private keys) 6 | public on github. 7 | -------------------------------------------------------------------------------- /tests/assets/rootCA.cnf: -------------------------------------------------------------------------------- 1 | [req] 2 | default_bits = 4096 3 | prompt = no 4 | default_md = sha256 5 | distinguished_name = dn 6 | 7 | [dn] 8 | C=US 9 | ST=SomeState 10 | L=SomeCity 11 | O=Test CA 12 | OU=SomeOrganizationUnit 13 | emailAddress=test@example.com 14 | CN = Test CA 15 | -------------------------------------------------------------------------------- /tests/assets/rootCA.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDuTCCAqECFA89mdXXMCnDsJ4Ur/KBUaNZ2AAYMA0GCSqGSIb3DQEBCwUAMIGY 3 | MQswCQYDVQQGEwJVUzESMBAGA1UECAwJU29tZVN0YXRlMREwDwYDVQQHDAhTb21l 4 | Q2l0eTEQMA4GA1UECgwHVGVzdCBDQTEdMBsGA1UECwwUU29tZU9yZ2FuaXphdGlv 5 | blVuaXQxHzAdBgkqhkiG9w0BCQEWEHRlc3RAZXhhbXBsZS5jb20xEDAOBgNVBAMM 6 | B1Rlc3QgQ0EwHhcNMTkwODI4MTk0NzIwWhcNNDkxMjMxMTk0NzIwWjCBmDELMAkG 7 | A1UEBhMCVVMxEjAQBgNVBAgMCVNvbWVTdGF0ZTERMA8GA1UEBwwIU29tZUNpdHkx 8 | EDAOBgNVBAoMB1Rlc3QgQ0ExHTAbBgNVBAsMFFNvbWVPcmdhbml6YXRpb25Vbml0 9 | MR8wHQYJKoZIhvcNAQkBFhB0ZXN0QGV4YW1wbGUuY29tMRAwDgYDVQQDDAdUZXN0 10 | IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1B6TAFqG5tNXXH4v 11 | uc7lqMc9FUDgjAeAlxH5pb2b5pmKdZoNFE4R5UiJ90Hb1P2kUmWywUIYiQ1QKOtr 12 | 8/qPzenkd6Y6myOBtHlW8ktodnfXKzhmxzdNMWqrikHOSiZXxOJfwmNMCOuLd9lo 13 | 12NI00ZqiS6XATW7vFZd3fw/IojwoN+RyKFmRMy27d3jtxTSqx37+jVerETmL4zA 14 | G01VfVrIm2Mx0ZHq4OHoQfsc2X1UrBjU68evjc2pJ+gJ2GN0NlvQ8lpwD8rguDQf 15 | HCF/VjGOsZyxQWHfmYKK5F/UXi/k9GwqNQ/adNV8drWm3OqqFVKNRLAviQNm+507 16 | kNfYIwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAajndlgIF/rjP7aeSZ6h033BVW 17 | JEbpb5Ke1Arvj5UypH/hlhpfi0KIeoYR82qdPoDTYoFDp62yGQmkCBDJDQ1mgFhl 18 | 8UcVY6zOuMH6J94JgvXneFTAsCFNsLF4g1EeKvLZ7EB+339AFXI+1jRrJHnEJD7+ 19 | CNiLYdLTqpGHie3AAPf+9ImJsvyhOL3eWq5Z9t+/5rrF2bqoy9sJTYo+pVstLbVU 20 | QGoA+PVTlOGI8N2KmhsYU+tKKpKgItUttTPqzzdZY8nw33BYfkHRp2tSIz8XHa+F 21 | wAVAzi5DRKKMWWROfHOy5WwEOwksIGul7z9/RE1tGonpxRTp6CCBy5hl4qs8 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /tests/assets/rootCA.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEA1B6TAFqG5tNXXH4vuc7lqMc9FUDgjAeAlxH5pb2b5pmKdZoN 3 | FE4R5UiJ90Hb1P2kUmWywUIYiQ1QKOtr8/qPzenkd6Y6myOBtHlW8ktodnfXKzhm 4 | xzdNMWqrikHOSiZXxOJfwmNMCOuLd9lo12NI00ZqiS6XATW7vFZd3fw/IojwoN+R 5 | yKFmRMy27d3jtxTSqx37+jVerETmL4zAG01VfVrIm2Mx0ZHq4OHoQfsc2X1UrBjU 6 | 68evjc2pJ+gJ2GN0NlvQ8lpwD8rguDQfHCF/VjGOsZyxQWHfmYKK5F/UXi/k9Gwq 7 | NQ/adNV8drWm3OqqFVKNRLAviQNm+507kNfYIwIDAQABAoIBAQCEbU4TE3FTHDBX 8 | Q0RieUOx2ilNYoKNiYWDSyqTicuR7NufTfzKZ28OuPnBTvGCiJOVCX7O3ofY0GHy 9 | Gf/8grpSsKn89N9zyXgJhxN8Ew31oB3KEt0/lEvyBQlxOGIksslq1nU4M6vdicg5 10 | m5azmX12wWhzmo2VqMk67KSPmRKFaNu20WJcIa/cNA3fJGkkiLmZBDmsrcVMH62s 11 | FfqpHrMr6BXQl90RDSIi6UoVzZ8u8kjZOczz8SJx23borr7UGlKJX2cRx37KJ6w3 12 | r3NScu+FPKtjY7WbiS7coA/105BF8kvhXWrMBC9IrdRRvze/pBoo2/09vbRrBAXR 13 | /fFRaRLJAoGBAPKBOM4OHZ2gOtkdFhUEAvQmPIf+Ule+SpUYMUmvKF3Umyu+DJpW 14 | F6tH7zP28yY/zIhfEHXq+O1YHe9u8QFQ/GOiT6sf3O4mW/QJvTvP3DlilVjdj9+r 15 | IBwX06rwZQ0uVrPsYh+egOtfBjeHhZ+Ikl9W/n55jILMYAWiHOBBTKCFAoGBAN/s 16 | edBm2pREaijtl3VyDjITMcMQrpA2W4MnaiL0gr5eEJjjIlYhO79RmSfpzRYQB7t6 17 | r19JTJtix8DavEjJt9/iCi4pWDtRVpulgBkTjoKZMOA9e+pwDcEK5ZZneNJ6NTGV 18 | iVyJtozybSLv517mzOvdTiHEG2Imgg0Jw+kDogqHAoGBANzPYQPuwnS2Yx8yZtr3 19 | 7iCVeGRz5FJUyOB9SNPJE75sSmZIBH27io3BUENGxxu61+gMd5aHP+YNaCSOJhCG 20 | x2mJb3Vn6+lFMFFDVPVTTTlLVKW4CLsmvHQYFfn+LmUUHopx4N7dmpG20phZZAhh 21 | eLYrJkvPLWwj4AMBG73ud3FpAoGBAK3DK6P0rJlxnY+1D1sr7qgdDPh58XwnMkxM 22 | QahuZSakh+ycFQEROPP1tguq+mKsfdOWGJCwqKnLtYaKNqGlJvKszYmUu8sMC1Es 23 | 1IKhEm11wt+/1nDOE15BvndARBnQi4a2q6kLlIU7ekUqNTkHkO1XBlJdg4Jer3y+ 24 | nzAqiYvxAoGABzVsHeI9Hlm2pcMbMIRHMXHXXJpl6kmB6OI4T+FFfbvR1yy16i9A 25 | wTwes3Dqjtoxz+ykdJAh8w1MF2jCfC0jDb3TxMJqs04HwvelBWe2rN9s5akdh1Ft 26 | 8YbziRMOfxFZ9ab8upBHYdT7Q9OSmv7qJfkc8YcpKjjOh+vzqbD2ajI= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /tests/assets/rootCA.srl: -------------------------------------------------------------------------------- 1 | D647D966F3C4188D 2 | -------------------------------------------------------------------------------- /tests/assets/server.cnf: -------------------------------------------------------------------------------- 1 | [req] 2 | default_bits = 4096 3 | prompt = no 4 | default_md = sha256 5 | distinguished_name = dn 6 | 7 | [dn] 8 | C=US 9 | ST=SomeState 10 | L=SomeCity 11 | O=Test cert 12 | OU=SomeOrganizationUnit 13 | emailAddress=test@example.com 14 | CN = localhost 15 | -------------------------------------------------------------------------------- /tests/assets/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEsDCCA5igAwIBAgIJANZH2WbzxBiNMA0GCSqGSIb3DQEBCwUAMIGYMQswCQYD 3 | VQQGEwJVUzESMBAGA1UECAwJU29tZVN0YXRlMREwDwYDVQQHDAhTb21lQ2l0eTEQ 4 | MA4GA1UECgwHVGVzdCBDQTEdMBsGA1UECwwUU29tZU9yZ2FuaXphdGlvblVuaXQx 5 | HzAdBgkqhkiG9w0BCQEWEHRlc3RAZXhhbXBsZS5jb20xEDAOBgNVBAMMB1Rlc3Qg 6 | Q0EwHhcNMTkwODI4MTk0NzIwWhcNNDkxMjMxMTk0NzIwWjCBnDELMAkGA1UEBhMC 7 | VVMxEjAQBgNVBAgMCVNvbWVTdGF0ZTERMA8GA1UEBwwIU29tZUNpdHkxEjAQBgNV 8 | BAoMCVRlc3QgY2VydDEdMBsGA1UECwwUU29tZU9yZ2FuaXphdGlvblVuaXQxHzAd 9 | BgkqhkiG9w0BCQEWEHRlc3RAZXhhbXBsZS5jb20xEjAQBgNVBAMMCWxvY2FsaG9z 10 | dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMtWmnFZdWPRN0v2bQSW 11 | /Plmi/qO9VQWcc+5g1Bmpm/KlfjBqB0NcNWXi8bxIPlDyv6B+1OsrcfCAnUKkQsT 12 | HHYFsnyJByC9Hz7OGQdrdPYsyugvRbTA/JTgR9xr9w7xENYNV+ZKg+bEe8b/MNor 13 | xuteuM3EklTze0V3RrDDuq33Vuunkqaz5varResyMfVGLxDcvqcRi2OkysdaKoc8 14 | z3DmvkZggwe1IarbWXVHmAGT45BAK9dM3/UPTFQyjL88igZtwVEU37x9x7RV8zSn 15 | 5MO2KeRLDfVthyxfYQjSk/D+CxM3v0L/SXJS3/ZJv0zd3/GdHJhFRcatEnRsIeAM 16 | g9cCAwEAAaOB9jCB8zCBwgYDVR0jBIG6MIG3oYGepIGbMIGYMQswCQYDVQQGEwJV 17 | UzESMBAGA1UECAwJU29tZVN0YXRlMREwDwYDVQQHDAhTb21lQ2l0eTEQMA4GA1UE 18 | CgwHVGVzdCBDQTEdMBsGA1UECwwUU29tZU9yZ2FuaXphdGlvblVuaXQxHzAdBgkq 19 | hkiG9w0BCQEWEHRlc3RAZXhhbXBsZS5jb20xEDAOBgNVBAMMB1Rlc3QgQ0GCFA89 20 | mdXXMCnDsJ4Ur/KBUaNZ2AAYMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMBQGA1Ud 21 | EQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAx5LINgzj+89wkjy5 22 | 5TpzgV6rcgAK8av1NLXztWwnbGtq9WDYc/3o1HIkEuHY6kkfsqhby3Vg1NKUS1MZ 23 | WiB1vvT4TyCwz0FoldVXRbIevUVtitFNigbfOOWQ3F/+pK7gfHM+psLDzvh2qAxO 24 | 6zUSfmCr3QFUjs7Bc0o5qMyaLL/SUdCps534yWoRemLmo89Pf9OgdYsbNWKhSRM2 25 | uq5rPmLkIQMWbAJFGc4KejusBswzDNP/+yaLnnhluI90j07CZBfOiMnQcSzLVYrd 26 | LzJ+YJKL3ZuwaK1Yg0J5xgaBc3fr3+XoF7rQV4HRfk4kvT46xmrlropBakS4T9g7 27 | YGvslw== 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /tests/assets/server.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIIC4jCCAcoCAQAwgZwxCzAJBgNVBAYTAlVTMRIwEAYDVQQIDAlTb21lU3RhdGUx 3 | ETAPBgNVBAcMCFNvbWVDaXR5MRIwEAYDVQQKDAlUZXN0IGNlcnQxHTAbBgNVBAsM 4 | FFNvbWVPcmdhbml6YXRpb25Vbml0MR8wHQYJKoZIhvcNAQkBFhB0ZXN0QGV4YW1w 5 | bGUuY29tMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IB 6 | DwAwggEKAoIBAQDLVppxWXVj0TdL9m0Elvz5Zov6jvVUFnHPuYNQZqZvypX4wagd 7 | DXDVl4vG8SD5Q8r+gftTrK3HwgJ1CpELExx2BbJ8iQcgvR8+zhkHa3T2LMroL0W0 8 | wPyU4Efca/cO8RDWDVfmSoPmxHvG/zDaK8brXrjNxJJU83tFd0aww7qt91brp5Km 9 | s+b2q0XrMjH1Ri8Q3L6nEYtjpMrHWiqHPM9w5r5GYIMHtSGq21l1R5gBk+OQQCvX 10 | TN/1D0xUMoy/PIoGbcFRFN+8fce0VfM0p+TDtinkSw31bYcsX2EI0pPw/gsTN79C 11 | /0lyUt/2Sb9M3d/xnRyYRUXGrRJ0bCHgDIPXAgMBAAGgADANBgkqhkiG9w0BAQsF 12 | AAOCAQEAGxaH8686Uqlb6puKCf51Kk3Fabut1iNq0e0+zrHZ+Nkyk8GGfF+u/2s+ 13 | 8Ga8uxvdWHwhANvTWdnHhM6F+4qv12PUVOIQN+phZubR+chsbqR4OvIFDLyZ4Ot5 14 | +i+H5jUR25omV01n7l0HBDyK7aKaMB9upChzqsKJ9rN/sI8k253PAESbXkS3CtFv 15 | qx9yNYetQqZ8fA+pbwt0qHiyC0I8Nm1aJ20qjHXev9guB+hlHNxPJB3n8WzBlml1 16 | +03fsuimckEVMiDVY4DSUAl2x09SWIwKdK9mXvUEyY7njf1iYzgY7NUMqlm8eHEh 17 | hwXfn88GVfDjLCM/4SaZYDHYyMIUPg== 18 | -----END CERTIFICATE REQUEST----- 19 | -------------------------------------------------------------------------------- /tests/assets/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDLVppxWXVj0TdL 3 | 9m0Elvz5Zov6jvVUFnHPuYNQZqZvypX4wagdDXDVl4vG8SD5Q8r+gftTrK3HwgJ1 4 | CpELExx2BbJ8iQcgvR8+zhkHa3T2LMroL0W0wPyU4Efca/cO8RDWDVfmSoPmxHvG 5 | /zDaK8brXrjNxJJU83tFd0aww7qt91brp5Kms+b2q0XrMjH1Ri8Q3L6nEYtjpMrH 6 | WiqHPM9w5r5GYIMHtSGq21l1R5gBk+OQQCvXTN/1D0xUMoy/PIoGbcFRFN+8fce0 7 | VfM0p+TDtinkSw31bYcsX2EI0pPw/gsTN79C/0lyUt/2Sb9M3d/xnRyYRUXGrRJ0 8 | bCHgDIPXAgMBAAECggEBAILvVGoy+dV5zkq69v5in6mVcAO69JzeCxGA2t72Cqyn 9 | 3iSdxzxWAFd5y4cilGIOVnPGjWkXI5wjAOQPFtDz0HrrNkDdF9rsDWeq3REbD+jJ 10 | dStH7XH0Z3ESbxOv7hoP/xBm7TgcuYzq7u14PCPw1pmPmB7gOn47fyB/KuT+VIJa 11 | QYVi7kHW4dYkV1nqgZ8Xak4HSqJ8EAWbqcIWYs0tZ70BrACIPZPrUxEIK1Eq1o9M 12 | yJFdcCru1tNDaG49JAiuQOAsLAg9bKFStoJPjgdPf+3E4A4vvOWe+PkPEWZe9X9D 13 | pJSSg+uZ2UXEpqBSpqP3SriZXYPt0DmEKTBWxzhnEEkCgYEA86ZTy3GXcIVttWKe 14 | 1dixY2os54aeUJb63GyoqgzDAXjW1sX5sa5D9yr/y64Sv2ouEFOotSSu9UAImpuE 15 | l67o1kfDKWm4gArr8pc5kHxkw/Jex/6/LEYwYMAvvYz4KI4pBAeSVJrpzAyd/H34 16 | Wuqx42QJtQBKK7X5FifBgYdk6ZUCgYEA1aUuxsWn6iMLxrcdzjtINpuYH56Jvo86 17 | JyXKApCXiRpYiPJE2KoM52F6BOwnngiRG7unhhRRs9Tj+R3ieIUCOsamM0AxDLvU 18 | 46wm0tFqYyHDIaYq/dqTnsnAQqDJxL76Guk4SFbe97AzOJKuE/KpyIrCs8JZ8KGM 19 | s9wgXjetVLsCgYB2mnqjx/GuBmV3LEChXFBNUnv1YCRkmnoXHpWj4X5zkv0Ro2F+ 20 | ypOvF7FY1q2tm+Q8clzngKniHH+TsyyCIdSZqmkoGZyER8y/VDnjSYpLkAnvVOR+ 21 | itQ15JfYr2yFYV455e2nXZl9iI7HQBLLPv/E7weCold4m6Za0JNzmBLZWQKBgE7P 22 | 0KGi6H6GzyFPC7+4PrtmSoffhBC35UvrtMmdbUk2XtKmDJ+gm4H/g3Otai/yGRWR 23 | 9AqSFFGyhyauz4yGBHyKK2VcmLuJzs7uAqRifEx1d/ZBxjo/F5XL9xCdH9FkYf7r 24 | acfFxBq69So5cd4J9nf0OD73wxXxgmYXHhmjkF1hAoGBAPFz3EL5syk0aVWclIiZ 25 | R5HDlf39dQAhbfjGUBRRBEcyQzvCO37q2esO0deeLh8o5kaHZZ5ymoBQ+rPJEXx3 26 | 6HY4oJLVp9BP50GY5955KTKGDhRScTmcn5b/zLQoQsGIFH8xv/HNx3zbnB8HzmXo 27 | YV96kc0NhvB0XSGg9OfnbzTS 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/assets/v3.ext: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier=keyid,issuer 2 | basicConstraints=CA:FALSE 3 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 4 | subjectAltName = @alt_names 5 | 6 | [alt_names] 7 | DNS.1 = localhost 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def pytest_addoption(parser): 5 | parser.addoption("--ssl", action="store_true", default=False, help="run ssl tests") 6 | parser.addoption("--release", action="store_true", default=False, help="run release tests") 7 | 8 | 9 | def pytest_runtest_setup(item): 10 | markers = [marker.name for marker in item.iter_markers()] 11 | 12 | if not item.config.getoption("--ssl") and "ssl" in markers: 13 | pytest.skip() 14 | if item.config.getoption("--ssl") and "ssl" not in markers: 15 | pytest.skip() 16 | if not item.config.getoption("--release") and "release" in markers: 17 | pytest.skip() 18 | -------------------------------------------------------------------------------- /tests/examples/test_example_blocking_httpserver.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from queue import Queue 3 | 4 | import pytest 5 | import requests 6 | 7 | from pytest_httpserver import BlockingHTTPServer 8 | 9 | # override httpserver fixture 10 | 11 | 12 | @pytest.fixture 13 | def httpserver(): 14 | server = BlockingHTTPServer(timeout=1) 15 | server.start() 16 | 17 | yield server 18 | 19 | if server.is_running(): 20 | server.stop() 21 | 22 | # this is to check if the client has made any request where no 23 | # `assert_request` was called on it from the test 24 | 25 | server.check_assertions() 26 | server.clear() 27 | 28 | 29 | def test_simplified(httpserver: BlockingHTTPServer): 30 | def client(response_queue: Queue): 31 | response = requests.get(httpserver.url_for("/foobar"), timeout=10) 32 | response_queue.put(response) 33 | 34 | # start the client, server is not yet configured 35 | # it will block until we add a request handler to the server 36 | # (see the timeout parameter of the http server) 37 | response_queue: Queue[requests.models.Response] = Queue(maxsize=1) 38 | thread = threading.Thread(target=client, args=(response_queue,)) 39 | thread.start() 40 | 41 | try: 42 | # check that the request is for /foobar and it is a GET method 43 | # if this does not match, it will raise AssertionError and test will fail 44 | client_connection = httpserver.assert_request(uri="/foobar", method="GET") 45 | 46 | # with the received client_connection, we now need to send back the response 47 | # this makes the request.get() call in client() to return 48 | client_connection.respond_with_json({"foo": "bar"}) 49 | 50 | finally: 51 | # wait for the client thread to complete 52 | thread.join(timeout=1) 53 | assert not thread.is_alive() # check if join() has not timed out 54 | 55 | # check the response the client received 56 | response = response_queue.get(timeout=1) 57 | assert response.status_code == 200 58 | assert response.json() == {"foo": "bar"} 59 | -------------------------------------------------------------------------------- /tests/examples/test_example_query_params1.py: -------------------------------------------------------------------------------- 1 | def test_query_params(httpserver): 2 | httpserver.expect_request("/foo", query_string={"user": "user1"}).respond_with_data("OK") 3 | -------------------------------------------------------------------------------- /tests/examples/test_example_query_params2.py: -------------------------------------------------------------------------------- 1 | def test_query_params(httpserver): 2 | expected_query = {"user": "user1"} 3 | httpserver.expect_request("/foo", query_string=expected_query).respond_with_data("OK") 4 | -------------------------------------------------------------------------------- /tests/examples/test_howto_authorization_headers.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pytest_httpserver import HTTPServer 4 | 5 | 6 | def test_authorization_headers(httpserver: HTTPServer): 7 | headers_with_values_in_direct_order = { 8 | "Authorization": ( 9 | 'Digest username="Mufasa",' 10 | 'realm="testrealm@host.com",' 11 | 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",' 12 | 'uri="/dir/index.html",' 13 | "qop=auth," 14 | "nc=00000001," 15 | 'cnonce="0a4f113b",' 16 | 'response="6629fae49393a05397450978507c4ef1",' 17 | 'opaque="5ccc069c403ebaf9f0171e9517f40e41"' 18 | ) 19 | } 20 | httpserver.expect_request(uri="/", headers=headers_with_values_in_direct_order).respond_with_data("OK") 21 | response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_direct_order) 22 | assert response.status_code == 200 23 | assert response.text == "OK" 24 | 25 | headers_with_values_in_modified_order = { 26 | "Authorization": ( 27 | "Digest qop=auth," 28 | 'username="Mufasa",' 29 | 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",' 30 | 'uri="/dir/index.html",' 31 | "nc=00000001," 32 | 'realm="testrealm@host.com",' 33 | 'response="6629fae49393a05397450978507c4ef1",' 34 | 'cnonce="0a4f113b",' 35 | 'opaque="5ccc069c403ebaf9f0171e9517f40e41"' 36 | ) 37 | } 38 | response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_modified_order) 39 | assert response.status_code == 200 40 | assert response.text == "OK" 41 | -------------------------------------------------------------------------------- /tests/examples/test_howto_case_insensitive_matcher.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import requests 4 | 5 | from pytest_httpserver import HTTPServer 6 | 7 | 8 | def case_insensitive_matcher(header_name: str, actual: Optional[str], expected: str) -> bool: 9 | if actual is None: 10 | return False 11 | 12 | if header_name == "X-Foo": 13 | return actual.lower() == expected.lower() 14 | else: 15 | return actual == expected 16 | 17 | 18 | def test_case_insensitive_matching(httpserver: HTTPServer): 19 | httpserver.expect_request( 20 | "/", header_value_matcher=case_insensitive_matcher, headers={"X-Foo": "bar"} 21 | ).respond_with_data("OK") 22 | 23 | assert requests.get(httpserver.url_for("/"), headers={"X-Foo": "bar"}).status_code == 200 24 | assert requests.get(httpserver.url_for("/"), headers={"X-Foo": "BAR"}).status_code == 200 25 | -------------------------------------------------------------------------------- /tests/examples/test_howto_check.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_httpserver import HTTPServer 5 | 6 | 7 | @pytest.mark.xfail 8 | def test_check_assertions(httpserver: HTTPServer): 9 | def handler(_): 10 | assert 1 == 2 11 | 12 | httpserver.expect_request("/foobar").respond_with_handler(handler) 13 | 14 | requests.get(httpserver.url_for("/foobar")) 15 | 16 | # this will raise AssertionError: 17 | httpserver.check() 18 | -------------------------------------------------------------------------------- /tests/examples/test_howto_check_handler_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_httpserver import HTTPServer 5 | 6 | 7 | def test_check_assertions_raises_handler_assertions(httpserver: HTTPServer): 8 | def handler(_): 9 | assert 1 == 2 10 | 11 | httpserver.expect_request("/foobar").respond_with_handler(handler) 12 | 13 | requests.get(httpserver.url_for("/foobar")) 14 | 15 | # if you leave this "with" statement out, check_assertions() will break 16 | # the test by re-raising the assertion error caused by the handler 17 | # pytest will pick this exception as it was happened in the main thread 18 | with pytest.raises(AssertionError): 19 | httpserver.check_assertions() 20 | 21 | httpserver.check_handler_errors() 22 | 23 | 24 | def test_check_handler_errors_raises_handler_error(httpserver: HTTPServer): 25 | def handler(_): 26 | raise ValueError("should be propagated") 27 | 28 | httpserver.expect_request("/foobar").respond_with_handler(handler) 29 | 30 | requests.get(httpserver.url_for("/foobar")) 31 | 32 | httpserver.check_assertions() 33 | 34 | # if you leave this "with" statement out, check_handler_errors() will 35 | # break the test with the original exception 36 | with pytest.raises(ValueError): 37 | httpserver.check_handler_errors() 38 | -------------------------------------------------------------------------------- /tests/examples/test_howto_custom_handler.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from werkzeug import Request 4 | from werkzeug import Response 5 | 6 | from pytest_httpserver import HTTPServer 7 | 8 | 9 | def test_expected_request_handler(httpserver: HTTPServer): 10 | def handler(request: Request): 11 | return Response(str(randint(1, 10))) 12 | 13 | httpserver.expect_request("/foobar").respond_with_handler(handler) 14 | -------------------------------------------------------------------------------- /tests/examples/test_howto_custom_hooks.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from werkzeug import Request 3 | from werkzeug import Response 4 | 5 | from pytest_httpserver import HTTPServer 6 | 7 | 8 | def my_hook(_request: Request, response: Response) -> Response: 9 | # add a new header value to the response 10 | response.headers["X-Example"] = "Example" 11 | return response 12 | 13 | 14 | def test_custom_hook(httpserver: HTTPServer): 15 | httpserver.expect_request("/foo").with_post_hook(my_hook).respond_with_data(b"OK") 16 | 17 | assert requests.get(httpserver.url_for("/foo")).headers["X-Example"] == "Example" 18 | -------------------------------------------------------------------------------- /tests/examples/test_howto_custom_request_matcher.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from werkzeug import Request 3 | 4 | from pytest_httpserver import HTTPServer 5 | from pytest_httpserver import RequestMatcher 6 | 7 | 8 | class MyMatcher(RequestMatcher): 9 | def match(self, request: Request) -> bool: 10 | match = super().match(request) 11 | if not match: # existing parameters didn't match -> return with False 12 | return match 13 | 14 | # match the json's "value" key: if it is an integer and it is an even 15 | # number, it returns True 16 | json = request.json 17 | if isinstance(json, dict) and isinstance(json.get("value"), int): 18 | return json["value"] % 2 == 0 19 | 20 | return False 21 | 22 | 23 | def test_custom_request_matcher(httpserver: HTTPServer): 24 | httpserver.expect(MyMatcher("/foo")).respond_with_data("OK") 25 | 26 | # with even number it matches the request 27 | resp = requests.post(httpserver.url_for("/foo"), json={"value": 42}) 28 | resp.raise_for_status() 29 | assert resp.text == "OK" 30 | 31 | resp = requests.post(httpserver.url_for("/foo"), json={"value": 198}) 32 | resp.raise_for_status() 33 | assert resp.text == "OK" 34 | 35 | # with an odd number, it does not match the request 36 | resp = requests.post(httpserver.url_for("/foo"), json={"value": 43}) 37 | assert resp.status_code == 500 38 | -------------------------------------------------------------------------------- /tests/examples/test_howto_header_value_matcher.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import requests 4 | 5 | from pytest_httpserver import HeaderValueMatcher 6 | from pytest_httpserver import HTTPServer 7 | 8 | 9 | def case_insensitive_compare(actual: Optional[str], expected: str) -> bool: 10 | # actual is `None` if it is not specified 11 | if actual is None: 12 | return False 13 | return actual.lower() == expected.lower() 14 | 15 | 16 | def test_own_matcher_object(httpserver: HTTPServer): 17 | matcher = HeaderValueMatcher({"X-Bar": case_insensitive_compare}) 18 | 19 | httpserver.expect_request("/", headers={"X-Bar": "bar"}, header_value_matcher=matcher).respond_with_data("OK") 20 | 21 | assert requests.get(httpserver.url_for("/"), headers={"X-Bar": "bar"}).status_code == 200 22 | assert requests.get(httpserver.url_for("/"), headers={"X-Bar": "BAR"}).status_code == 200 23 | -------------------------------------------------------------------------------- /tests/examples/test_howto_hooks.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pytest_httpserver import HTTPServer 4 | from pytest_httpserver.hooks import Delay 5 | 6 | 7 | def test_delay(httpserver: HTTPServer): 8 | # this adds 0.5 seconds delay to the server response 9 | httpserver.expect_request("/foo").with_post_hook(Delay(0.5)).respond_with_json({"example": "foo"}) 10 | 11 | assert requests.get(httpserver.url_for("/foo")).json() == {"example": "foo"} 12 | -------------------------------------------------------------------------------- /tests/examples/test_howto_json_matcher.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pytest_httpserver import HTTPServer 4 | 5 | 6 | def test_json_matcher(httpserver: HTTPServer): 7 | httpserver.expect_request("/foo", json={"foo": "bar"}).respond_with_data("Hello world!") 8 | resp = requests.get(httpserver.url_for("/foo"), json={"foo": "bar"}) 9 | assert resp.status_code == 200 10 | assert resp.text == "Hello world!" 11 | -------------------------------------------------------------------------------- /tests/examples/test_howto_log_querying.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pytest_httpserver import HTTPServer 4 | from pytest_httpserver import RequestMatcher 5 | 6 | 7 | def test_log_querying_example(httpserver: HTTPServer): 8 | # set up the handler for the request 9 | httpserver.expect_request("/foo").respond_with_data("OK") 10 | 11 | # make a request matching the handler 12 | assert requests.get(httpserver.url_for("/foo")).text == "OK", "Response should be 'OK'" 13 | 14 | # make another request non-matching and handler 15 | assert ( 16 | requests.get(httpserver.url_for("/no_match")).status_code == 500 17 | ), "Response code should be 500 for non-matched requests" 18 | 19 | # you can query the log directly 20 | # log will contain all request-response pair, including non-matching 21 | # requests and their response as well 22 | assert len(httpserver.log) == 2, "2 request-response pairs should be in the log" 23 | 24 | # there are the following methods to query the log 25 | # 26 | # each one uses the matcher we created for the handler in the very beginning 27 | # of this test, RequestMatcher accepts the same parameters what you were 28 | # specifying to the `expect_request` (and similar) methods. 29 | 30 | # 1. get counts 31 | # (returns 0 for non-matches) 32 | httpserver.get_matching_requests_count( 33 | RequestMatcher("/foo") 34 | ) == 1, "There should be one request matching the the /foo request" 35 | 36 | # 2. assert for matching request counts 37 | # by default it asserts for exactly 1 matches 38 | # it is roughly the same as: 39 | # ``` 40 | # assert httpserver.get_matching_requests_count(...) == 1 41 | # ``` 42 | # assertion text will be a fully-detailed explanation about the error, including 43 | # the similar handlers (which might have been inproperly configured) 44 | httpserver.assert_request_made(RequestMatcher("/foo")) 45 | 46 | # you can also specify the counts 47 | # if you want, you can specify 0 to check for non-matching requests 48 | 49 | # there should have been 0 requests for /bar 50 | httpserver.assert_request_made(RequestMatcher("/bar"), count=0) 51 | 52 | # 3. iterate over the matching request-response pairs 53 | # this provides you greater flexibility 54 | for request, response in httpserver.iter_matching_requests(RequestMatcher("/foo")): 55 | assert request.url == httpserver.url_for("/foo") 56 | assert response.get_data() == b"OK" 57 | -------------------------------------------------------------------------------- /tests/examples/test_howto_query_params_dict.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def test_query_params(httpserver): 5 | httpserver.expect_request("/foo", query_string={"user": "user1", "group": "group1"}).respond_with_data("OK") 6 | 7 | assert requests.get(httpserver.url_for("/foo?user=user1&group=group1")).status_code == 200 8 | assert requests.get(httpserver.url_for("/foo?group=group1&user=user1")).status_code == 200 9 | -------------------------------------------------------------------------------- /tests/examples/test_howto_query_params_never_do_this.py: -------------------------------------------------------------------------------- 1 | def test_query_params(httpserver): 2 | httpserver.expect_request("/foo?user=bar") # never do this 3 | -------------------------------------------------------------------------------- /tests/examples/test_howto_query_params_proper_use.py: -------------------------------------------------------------------------------- 1 | def test_query_params(httpserver): 2 | httpserver.expect_request("/foo", query_string="user=bar") 3 | -------------------------------------------------------------------------------- /tests/examples/test_howto_regexp.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import requests 4 | 5 | 6 | def test_httpserver_with_regexp(httpserver): 7 | httpserver.expect_request(re.compile("^/foo"), method="GET") 8 | requests.get(httpserver.url_for("/foobar")) 9 | -------------------------------------------------------------------------------- /tests/examples/test_howto_timeout_requests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | 5 | def test_connection_refused(): 6 | # assumes that there's no server listening at localhost:1234 7 | with pytest.raises(requests.exceptions.ConnectionError): 8 | requests.get("http://localhost:1234") 9 | -------------------------------------------------------------------------------- /tests/examples/test_howto_url_matcher.py: -------------------------------------------------------------------------------- 1 | from pytest_httpserver import HTTPServer 2 | from pytest_httpserver import URIPattern 3 | 4 | 5 | class PrefixMatch(URIPattern): 6 | def __init__(self, prefix: str): 7 | self.prefix = prefix 8 | 9 | def match(self, uri): 10 | return uri.startswith(self.prefix) 11 | 12 | 13 | def test_uripattern_object(httpserver: HTTPServer): 14 | httpserver.expect_request(PrefixMatch("/foo")).respond_with_json({"foo": "bar"}) 15 | -------------------------------------------------------------------------------- /tests/examples/test_howto_wait_success.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pytest_httpserver import HTTPServer 4 | 5 | 6 | def test_wait_success(httpserver: HTTPServer): 7 | waiting_timeout = 0.1 8 | 9 | with httpserver.wait(stop_on_nohandler=False, timeout=waiting_timeout) as waiting: 10 | requests.get(httpserver.url_for("/foobar")) 11 | httpserver.expect_oneshot_request("/foobar").respond_with_data("OK foobar") 12 | requests.get(httpserver.url_for("/foobar")) 13 | assert waiting.result 14 | 15 | httpserver.expect_oneshot_request("/foobar").respond_with_data("OK foobar") 16 | httpserver.expect_oneshot_request("/foobaz").respond_with_data("OK foobaz") 17 | with httpserver.wait(timeout=waiting_timeout) as waiting: 18 | requests.get(httpserver.url_for("/foobar")) 19 | requests.get(httpserver.url_for("/foobaz")) 20 | assert waiting.result 21 | -------------------------------------------------------------------------------- /tests/test_blocking_httpserver.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from copy import deepcopy 3 | from multiprocessing.pool import ThreadPool 4 | from urllib.parse import urlparse 5 | 6 | import pytest 7 | import requests 8 | 9 | from pytest_httpserver import BlockingHTTPServer 10 | 11 | 12 | @contextmanager 13 | def when_a_request_is_being_sent_to_the_server(request): 14 | with ThreadPool(1) as pool: 15 | yield pool.apply_async(requests.request, kwds=request) 16 | 17 | 18 | def then_the_server_gets_the_request(server, request): 19 | request = deepcopy(request) 20 | replace_url_with_uri(request) 21 | 22 | return server.assert_request(**request) 23 | 24 | 25 | def replace_url_with_uri(request): 26 | request["uri"] = get_uri(request["url"]) 27 | del request["url"] 28 | 29 | 30 | def get_uri(url): 31 | url = urlparse(url) 32 | return "?".join(item for item in [url.path, url.query] if item) 33 | 34 | 35 | def when_the_server_responds_to(client_connection, response): 36 | client_connection.respond_with_json(response) 37 | 38 | 39 | def then_the_response_is_got_from(server_connection, response): 40 | assert server_connection.get(timeout=9).json() == response 41 | 42 | 43 | @pytest.fixture 44 | def httpserver(): 45 | server = BlockingHTTPServer(timeout=1) 46 | server.start() 47 | 48 | yield server 49 | 50 | server.clear() 51 | if server.is_running(): 52 | server.stop() 53 | 54 | 55 | def test_behave_workflow(httpserver: BlockingHTTPServer): 56 | request = dict( 57 | method="GET", 58 | url=httpserver.url_for("/my/path"), 59 | ) 60 | 61 | with when_a_request_is_being_sent_to_the_server(request) as server_connection: 62 | client_connection = then_the_server_gets_the_request(httpserver, request) 63 | 64 | response = {"foo": "bar"} 65 | 66 | when_the_server_responds_to(client_connection, response) 67 | 68 | then_the_response_is_got_from(server_connection, response) 69 | 70 | 71 | def test_raises_assertion_error_when_request_does_not_match(httpserver: BlockingHTTPServer): 72 | request = dict( 73 | method="GET", 74 | url=httpserver.url_for("/my/path"), 75 | ) 76 | 77 | with when_a_request_is_being_sent_to_the_server(request): 78 | with pytest.raises(AssertionError) as exc: 79 | httpserver.assert_request(uri="/not/my/path/") 80 | 81 | assert "/not/my/path/" in str(exc) 82 | assert "does not match" in str(exc) 83 | 84 | 85 | def test_raises_assertion_error_when_request_was_not_sent(httpserver: BlockingHTTPServer): 86 | with pytest.raises(AssertionError) as exc: 87 | httpserver.assert_request(uri="/my/path/", timeout=1) 88 | 89 | assert "/my/path/" in str(exc) 90 | assert "timed out" in str(exc) 91 | 92 | 93 | def test_ignores_when_request_is_not_asserted(httpserver: BlockingHTTPServer): 94 | request = dict( 95 | method="GET", 96 | url=httpserver.url_for("/my/path"), 97 | ) 98 | 99 | with when_a_request_is_being_sent_to_the_server(request) as server_connection: 100 | assert ( 101 | server_connection.get(timeout=9).text == "No handler found for request " 102 | f" with data b''." 103 | ) 104 | 105 | 106 | def test_raises_assertion_error_when_request_was_not_responded(httpserver: BlockingHTTPServer): 107 | request = dict( 108 | method="GET", 109 | url=httpserver.url_for("/my/path"), 110 | ) 111 | 112 | with when_a_request_is_being_sent_to_the_server(request): 113 | then_the_server_gets_the_request(httpserver, request) 114 | 115 | httpserver.stop() # waiting for timeout of waiting for the response 116 | 117 | with pytest.raises(AssertionError) as exc: 118 | httpserver.check_assertions() 119 | 120 | assert "/my/path" in str(exc) 121 | assert "no response" in str(exc).lower() 122 | 123 | 124 | def test_repr(httpserver: BlockingHTTPServer): 125 | assert repr(httpserver) == f"" 126 | -------------------------------------------------------------------------------- /tests/test_handler_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | import werkzeug 4 | 5 | from pytest_httpserver import HTTPServer 6 | 7 | 8 | def test_check_assertions_raises_handler_assertions(httpserver: HTTPServer): 9 | def handler(_): 10 | assert False # noqa: PT015 11 | 12 | httpserver.expect_request("/foobar").respond_with_handler(handler) 13 | 14 | requests.get(httpserver.url_for("/foobar")) 15 | 16 | with pytest.raises(AssertionError): 17 | httpserver.check_assertions() 18 | 19 | httpserver.check_handler_errors() 20 | 21 | 22 | def test_check_handler_errors_raises_handler_error(httpserver: HTTPServer): 23 | def handler(_) -> werkzeug.Response: 24 | raise ValueError("should be propagated") 25 | 26 | httpserver.expect_request("/foobar").respond_with_handler(handler) 27 | 28 | requests.get(httpserver.url_for("/foobar")) 29 | 30 | httpserver.check_assertions() 31 | 32 | with pytest.raises(ValueError): # noqa: PT011 33 | httpserver.check_handler_errors() 34 | 35 | 36 | def test_check_handler_errors_correct_order(httpserver: HTTPServer): 37 | def handler1(_) -> werkzeug.Response: 38 | raise ValueError("should be propagated") 39 | 40 | def handler2(_) -> werkzeug.Response: 41 | raise OSError("should be propagated") 42 | 43 | httpserver.expect_request("/foobar1").respond_with_handler(handler1) 44 | httpserver.expect_request("/foobar2").respond_with_handler(handler2) 45 | 46 | requests.get(httpserver.url_for("/foobar1")) 47 | requests.get(httpserver.url_for("/foobar2")) 48 | 49 | httpserver.check_assertions() 50 | 51 | with pytest.raises(ValueError): # noqa: PT011 52 | httpserver.check_handler_errors() 53 | 54 | with pytest.raises(OSError): # noqa: PT011 55 | httpserver.check_handler_errors() 56 | 57 | httpserver.check_handler_errors() 58 | 59 | 60 | def test_missing_matcher_raises_exception(httpserver): 61 | requests.get(httpserver.url_for("/foobar")) 62 | 63 | # missing handlers should not raise handler exception here 64 | httpserver.check_handler_errors() 65 | 66 | with pytest.raises(AssertionError): 67 | httpserver.check_assertions() 68 | 69 | 70 | def test_check_raises_errors_in_order(httpserver): 71 | def handler1(_): 72 | assert False # noqa: PT015 73 | 74 | def handler2(_): 75 | pass # does nothing 76 | 77 | def handler3(_): 78 | raise ValueError 79 | 80 | httpserver.expect_request("/foobar1").respond_with_handler(handler1) 81 | httpserver.expect_request("/foobar2").respond_with_handler(handler2) 82 | httpserver.expect_request("/foobar3").respond_with_handler(handler3) 83 | 84 | requests.get(httpserver.url_for("/foobar1")) 85 | requests.get(httpserver.url_for("/foobar2")) 86 | requests.get(httpserver.url_for("/foobar3")) 87 | 88 | with pytest.raises(AssertionError): 89 | httpserver.check() 90 | 91 | with pytest.raises(ValueError): # noqa: PT011 92 | httpserver.check() 93 | -------------------------------------------------------------------------------- /tests/test_headers.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | 3 | import requests 4 | from werkzeug.datastructures import Headers 5 | from werkzeug.http import parse_dict_header 6 | 7 | from pytest_httpserver import HTTPServer 8 | from pytest_httpserver.httpserver import HeaderValueMatcher 9 | 10 | 11 | def test_custom_headers(httpserver: HTTPServer): 12 | headers_with_values_in_direct_order = {"Custom": 'Scheme key0="value0", key1="value1"'} 13 | httpserver.expect_request(uri="/", headers=headers_with_values_in_direct_order).respond_with_data("OK") 14 | response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_direct_order) 15 | assert response.status_code == 200 16 | assert response.text == "OK" 17 | 18 | # By default different order of items in header value dicts means different header values 19 | headers_with_values_in_modified_order = {"Custom": 'Scheme key1="value1", key0="value0"'} 20 | response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_modified_order) 21 | assert response.status_code == 500 22 | 23 | # Define header_value_matcher that ignores the order of items in header value dict 24 | def custom_header_value_matcher(actual: str, expected: str) -> bool: 25 | actual_scheme, _, actual_dict_str = actual.partition(" ") 26 | expected_scheme, _, expected_dict_str = expected.partition(" ") 27 | actual_dict = parse_dict_header(actual_dict_str) 28 | expected_dict = parse_dict_header(expected_dict_str) 29 | return actual_scheme == expected_scheme and actual_dict == expected_dict 30 | 31 | matchers = HeaderValueMatcher.DEFAULT_MATCHERS.copy() # type: ignore 32 | matchers["Custom"] = custom_header_value_matcher 33 | header_value_matcher = HeaderValueMatcher(matchers) 34 | 35 | httpserver.handlers.clear() 36 | httpserver.expect_request( 37 | uri="/", headers=headers_with_values_in_direct_order, header_value_matcher=header_value_matcher 38 | ).respond_with_data("OK") 39 | response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_modified_order) 40 | assert response.status_code == 200 41 | assert response.text == "OK" 42 | 43 | 44 | # See https://en.wikipedia.org/wiki/Digest_access_authentication 45 | def test_authorization_headers(httpserver: HTTPServer): 46 | headers_with_values_in_direct_order = { 47 | "Authorization": ( 48 | 'Digest username="Mufasa",' 49 | 'realm="testrealm@host.com",' 50 | 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",' 51 | 'uri="/dir/index.html",' 52 | "qop=auth," 53 | "nc=00000001," 54 | 'cnonce="0a4f113b",' 55 | 'response="6629fae49393a05397450978507c4ef1",' 56 | 'opaque="5ccc069c403ebaf9f0171e9517f40e41"' 57 | ) 58 | } 59 | httpserver.expect_request(uri="/", headers=headers_with_values_in_direct_order).respond_with_data("OK") 60 | response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_direct_order) 61 | assert response.status_code == 200 62 | assert response.text == "OK" 63 | 64 | headers_with_values_in_modified_order = { 65 | "Authorization": ( 66 | "Digest qop=auth," 67 | 'username="Mufasa",' 68 | 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",' 69 | 'uri="/dir/index.html",' 70 | "nc=00000001," 71 | 'realm="testrealm@host.com",' 72 | 'response="6629fae49393a05397450978507c4ef1",' 73 | 'cnonce="0a4f113b",' 74 | 'opaque="5ccc069c403ebaf9f0171e9517f40e41"' 75 | ) 76 | } 77 | response = requests.get(httpserver.url_for("/"), headers=headers_with_values_in_modified_order) 78 | assert response.status_code == 200 79 | assert response.text == "OK" 80 | 81 | 82 | def test_header_one_key_multiple_values(httpserver: HTTPServer): 83 | httpserver.expect_request(uri="/t1").respond_with_data(headers=[("X-Foo", "123"), ("X-Foo", "456")]) 84 | httpserver.expect_request(uri="/t2").respond_with_data(headers={"X-Foo": ["123", "456"]}) 85 | 86 | headers = Headers() 87 | headers.add("X-Foo", "123") 88 | headers.add("X-Foo", "456") 89 | 90 | httpserver.expect_request(uri="/t3").respond_with_data(headers=headers) 91 | 92 | for uri in ("/t1", "/t2", "/t3"): 93 | conn = http.client.HTTPConnection("localhost:{}".format(httpserver.port)) 94 | conn.request("GET", uri) 95 | response = conn.getresponse() 96 | conn.close() 97 | 98 | assert response.status == 200 99 | assert response.headers.get_all("X-Foo") == ["123", "456"] 100 | -------------------------------------------------------------------------------- /tests/test_hooks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | import requests 7 | 8 | from pytest_httpserver.hooks import Chain 9 | from pytest_httpserver.hooks import Delay 10 | from pytest_httpserver.hooks import Garbage 11 | 12 | if TYPE_CHECKING: 13 | from werkzeug import Request 14 | from werkzeug import Response 15 | 16 | from pytest_httpserver import HTTPServer 17 | 18 | 19 | class MyDelay(Delay): 20 | def __init__(self, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | self.evidence: int | None = None 23 | 24 | def _sleep(self): 25 | assert self.evidence is None, "_sleep should be called only once" 26 | self.evidence = self._seconds 27 | 28 | 29 | def suffix_hook_factory(suffix: bytes): 30 | def hook(_request: Request, response: Response) -> Response: 31 | response.set_data(response.get_data() + suffix) 32 | return response 33 | 34 | return hook 35 | 36 | 37 | def test_hook(httpserver: HTTPServer): 38 | my_hook = suffix_hook_factory(b"-SUFFIX") 39 | httpserver.expect_request("/foo").with_post_hook(my_hook).respond_with_data("OK") 40 | 41 | assert requests.get(httpserver.url_for("/foo")).text == "OK-SUFFIX" 42 | 43 | 44 | def test_delay_hook(httpserver: HTTPServer): 45 | delay = MyDelay(10) 46 | httpserver.expect_request("/foo").with_post_hook(delay).respond_with_data("OK") 47 | assert requests.get(httpserver.url_for("/foo")).text == "OK" 48 | assert delay.evidence == 10 49 | 50 | 51 | def test_garbage_hook(httpserver: HTTPServer): 52 | httpserver.expect_request("/prefix").with_post_hook(Garbage(prefix_size=128)).respond_with_data("OK") 53 | httpserver.expect_request("/suffix").with_post_hook(Garbage(suffix_size=128)).respond_with_data("OK") 54 | httpserver.expect_request("/both").with_post_hook(Garbage(prefix_size=128, suffix_size=128)).respond_with_data("OK") 55 | httpserver.expect_request("/large_prefix").with_post_hook(Garbage(prefix_size=10 * 1024 * 1024)).respond_with_data( 56 | "OK" 57 | ) 58 | 59 | resp_content = requests.get(httpserver.url_for("/prefix")).content 60 | assert len(resp_content) == 130 61 | assert resp_content[128:] == b"OK" 62 | 63 | resp_content = requests.get(httpserver.url_for("/large_prefix")).content 64 | assert len(resp_content) == 10 * 1024 * 1024 + 2 65 | assert resp_content[10 * 1024 * 1024 :] == b"OK" 66 | 67 | resp_content = requests.get(httpserver.url_for("/suffix")).content 68 | assert len(resp_content) == 130 69 | assert resp_content[:2] == b"OK" 70 | 71 | resp_content = requests.get(httpserver.url_for("/both")).content 72 | assert len(resp_content) == 258 73 | assert resp_content[128:130] == b"OK" 74 | 75 | with pytest.raises(AssertionError, match="prefix_size should be positive integer"): 76 | Garbage(-10) 77 | 78 | with pytest.raises(AssertionError, match="suffix_size should be positive integer"): 79 | Garbage(10, -10) 80 | 81 | 82 | def test_chain(httpserver: HTTPServer): 83 | delay = MyDelay(10) 84 | httpserver.expect_request("/foo").with_post_hook(Chain(delay, Garbage(128))).respond_with_data("OK") 85 | assert len(requests.get(httpserver.url_for("/foo")).content) == 130 86 | assert delay.evidence == 10 87 | 88 | 89 | def test_multiple_hooks(httpserver: HTTPServer): 90 | delay = MyDelay(10) 91 | httpserver.expect_request("/foo").with_post_hook(delay).with_post_hook(Garbage(128)).respond_with_data("OK") 92 | assert len(requests.get(httpserver.url_for("/foo")).content) == 130 93 | assert delay.evidence == 10 94 | 95 | 96 | def test_multiple_hooks_correct_order(httpserver: HTTPServer): 97 | hook1 = suffix_hook_factory(b"-S1") 98 | hook2 = suffix_hook_factory(b"-S2") 99 | 100 | httpserver.expect_request("/foo").with_post_hook(hook1).with_post_hook(hook2).respond_with_data("OK") 101 | 102 | assert requests.get(httpserver.url_for("/foo")).text == "OK-S1-S2" 103 | -------------------------------------------------------------------------------- /tests/test_ip_protocols.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def test_ipv4(httpserver_ipv4): 5 | httpserver_ipv4.expect_request("/").respond_with_data("OK") 6 | assert httpserver_ipv4.host == "127.0.0.1" 7 | 8 | response = requests.get(httpserver_ipv4.url_for("/")) 9 | assert response.text == "OK" 10 | 11 | 12 | def test_ipv6(httpserver_ipv6): 13 | httpserver_ipv6.expect_request("/").respond_with_data("OK") 14 | assert httpserver_ipv6.host == "::1" 15 | assert httpserver_ipv6.url_for("/") == f"http://[::1]:{httpserver_ipv6.port}/" 16 | 17 | response = requests.get(httpserver_ipv6.url_for("/")) 18 | assert response.text == "OK" 19 | -------------------------------------------------------------------------------- /tests/test_json_matcher.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import requests 5 | 6 | from pytest_httpserver import HTTPServer 7 | 8 | 9 | def test_json_matcher(httpserver: HTTPServer): 10 | httpserver.expect_request("/foo", json={"foo": "bar"}).respond_with_data("Hello world!") 11 | assert requests.get(httpserver.url_for("/foo")).status_code == 500 12 | resp = requests.get(httpserver.url_for("/foo"), json={"foo": "bar"}) 13 | assert resp.status_code == 200 14 | assert resp.text == "Hello world!" 15 | assert requests.get(httpserver.url_for("/foo"), json={"foo": "bar", "foo2": "bar2"}).status_code == 500 16 | 17 | 18 | def test_json_matcher_with_none(httpserver: HTTPServer): 19 | httpserver.expect_request("/foo", json=None).respond_with_data("Hello world!") 20 | resp = requests.get(httpserver.url_for("/foo"), data=json.dumps(None), headers={"content-type": "application/json"}) 21 | assert resp.status_code == 200 22 | assert resp.text == "Hello world!" 23 | 24 | 25 | def test_json_matcher_without_content_type(httpserver: HTTPServer): 26 | httpserver.expect_request("/foo", json={"foo": "bar"}).respond_with_data("Hello world!") 27 | assert requests.get(httpserver.url_for("/foo"), json={"foo": "bar"}).status_code == 200 28 | assert requests.get(httpserver.url_for("/foo"), data=json.dumps({"foo": "bar"})).status_code == 200 29 | 30 | 31 | def test_json_matcher_with_invalid_json(httpserver: HTTPServer): 32 | httpserver.expect_request("/foo", json={"foo": "bar"}).respond_with_data("Hello world!") 33 | assert requests.get(httpserver.url_for("/foo"), data="invalid-json").status_code == 500 34 | assert requests.get(httpserver.url_for("/foo"), data='{"invalid": "json"').status_code == 500 35 | assert requests.get(httpserver.url_for("/foo"), data=b"non-text\x1f\x8b").status_code == 500 36 | 37 | 38 | def test_data_and_json_mutually_exclusive(httpserver: HTTPServer): 39 | with pytest.raises(ValueError): # noqa: PT011 40 | httpserver.expect_request("/foo", json={}, data="foo") 41 | -------------------------------------------------------------------------------- /tests/test_log_leak.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_httpserver import HTTPServer 5 | 6 | 7 | class Client: 8 | def __init__(self) -> None: 9 | self.url: str | None = None 10 | 11 | def get(self): 12 | if self.url: 13 | requests.get(self.url) 14 | 15 | 16 | @pytest.fixture 17 | def my_fixture(): 18 | client = Client() 19 | yield client 20 | client.get() 21 | 22 | 23 | def test_1(my_fixture: Client, httpserver: HTTPServer): 24 | httpserver.expect_request("/foo").respond_with_data("OK") 25 | my_fixture.url = httpserver.url_for("/foo") 26 | 27 | 28 | def test_2(httpserver: HTTPServer): 29 | assert httpserver.log == [] 30 | -------------------------------------------------------------------------------- /tests/test_log_querying.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_httpserver import HTTPServer 5 | from pytest_httpserver import RequestMatcher 6 | 7 | 8 | def test_verify(httpserver: HTTPServer): 9 | httpserver.expect_request("/foo").respond_with_data("OK") 10 | httpserver.expect_request("/bar").respond_with_data("OKOK") 11 | 12 | assert list(httpserver.iter_matching_requests(httpserver.create_matcher("/foo"))) == [] 13 | assert requests.get(httpserver.url_for("/foo")).text == "OK" 14 | assert requests.get(httpserver.url_for("/bar")).text == "OKOK" 15 | 16 | matching_log = list(httpserver.iter_matching_requests(httpserver.create_matcher("/foo"))) 17 | assert len(matching_log) == 1 18 | 19 | request, response = matching_log[0] 20 | 21 | assert request.url == httpserver.url_for("/foo") 22 | assert response.get_data() == b"OK" 23 | 24 | assert httpserver.get_matching_requests_count(httpserver.create_matcher("/foo")) == 1 25 | httpserver.assert_request_made(httpserver.create_matcher("/foo")) 26 | httpserver.assert_request_made(httpserver.create_matcher("/no_match"), count=0) 27 | 28 | with pytest.raises(AssertionError): 29 | assert httpserver.assert_request_made(httpserver.create_matcher("/no_match")) 30 | 31 | with pytest.raises(AssertionError): 32 | assert httpserver.assert_request_made(httpserver.create_matcher("/foo"), count=2) 33 | 34 | 35 | def test_verify_assert_msg(httpserver: HTTPServer): 36 | httpserver.no_handler_status_code = 404 37 | httpserver.expect_request("/foo", json={"foo": "bar"}, method="POST").respond_with_data("OK") 38 | headers = {"User-Agent": "requests", "Accept-Encoding": "gzip, deflate"} 39 | assert requests.get(httpserver.url_for("/foo"), headers=headers).status_code == 404 40 | 41 | expected_lines = [ 42 | "Matching request found 0 times but expected 1 times.", 43 | "Expected request: ", 45 | "Found 1 similar request(s):", 46 | "--- Similar Request Start", 47 | "Path: /foo", 48 | "Method: GET", 49 | "Body: b''", 50 | f"Headers: Host: {httpserver.host}:{httpserver.port}", 51 | "User-Agent: requests", 52 | "Accept-Encoding: gzip, deflate", 53 | "Accept: */*", 54 | "Connection: keep-alive", 55 | "", 56 | "", 57 | "Query String: ''", 58 | "--- Similar Request End", 59 | ] 60 | 61 | with pytest.raises(AssertionError) as err: 62 | httpserver.assert_request_made(RequestMatcher("/foo", json={"foo": "bar"}, method="POST")) 63 | 64 | actual_lines = [x.strip() for x in str(err.value).splitlines()][: len(expected_lines)] 65 | assert actual_lines == expected_lines 66 | 67 | 68 | def test_verify_assert_msg_no_similar_requests(httpserver: HTTPServer): 69 | httpserver.expect_request("/foo", json={"foo": "bar"}, method="POST").respond_with_data("OK") 70 | 71 | expected_lines = [ 72 | "Matching request found 0 times but expected 1 times.", 73 | "Expected request: ", 75 | "No similar requests found.", 76 | ] 77 | 78 | with pytest.raises(AssertionError) as err: 79 | httpserver.assert_request_made(RequestMatcher("/foo", json={"foo": "bar"}, method="POST")) 80 | 81 | actual_lines = [x.strip() for x in str(err.value).splitlines()][: len(expected_lines)] 82 | assert actual_lines == expected_lines 83 | -------------------------------------------------------------------------------- /tests/test_matcher.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pytest_httpserver import HTTPServer 4 | 5 | 6 | def test_expect_method(httpserver: HTTPServer): 7 | expected_response = "OK" 8 | matcher = httpserver.create_matcher(uri="/test", method="POST") 9 | httpserver.expect(matcher).respond_with_data(expected_response) 10 | resp = requests.post(httpserver.url_for("/test"), json={"list": [1, 2, 3, 4]}) 11 | assert resp.text == expected_response 12 | -------------------------------------------------------------------------------- /tests/test_mixed.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | 4 | from pytest_httpserver import HTTPServer 5 | 6 | 7 | def _setup_oneshot(server: HTTPServer): 8 | server.expect_request("/permanent").respond_with_data("OK permanent") 9 | server.expect_oneshot_request("/oneshot1").respond_with_data("OK oneshot1") 10 | server.expect_oneshot_request("/oneshot2").respond_with_data("OK oneshot2") 11 | 12 | 13 | def _setup_ordered(server: HTTPServer): 14 | server.expect_ordered_request("/ordered1").respond_with_data("OK ordered1") 15 | server.expect_ordered_request("/ordered2").respond_with_data("OK ordered2") 16 | 17 | 18 | def _setup_all(server: HTTPServer): 19 | _setup_oneshot(server) 20 | _setup_ordered(server) 21 | 22 | 23 | def test_oneshot_and_permanent_happy_path1(httpserver: HTTPServer): 24 | _setup_oneshot(httpserver) 25 | assert requests.get(httpserver.url_for("/permanent")).text == "OK permanent" 26 | assert requests.get(httpserver.url_for("/oneshot1")).text == "OK oneshot1" 27 | assert requests.get(httpserver.url_for("/oneshot2")).text == "OK oneshot2" 28 | assert requests.get(httpserver.url_for("/permanent")).text == "OK permanent" 29 | 30 | assert len(httpserver.oneshot_handlers) == 0 31 | 32 | 33 | def test_oneshot_and_permanent_happy_path2(httpserver: HTTPServer): 34 | _setup_oneshot(httpserver) 35 | assert requests.get(httpserver.url_for("/permanent")).text == "OK permanent" 36 | assert requests.get(httpserver.url_for("/oneshot2")).text == "OK oneshot2" 37 | assert requests.get(httpserver.url_for("/oneshot1")).text == "OK oneshot1" 38 | assert requests.get(httpserver.url_for("/permanent")).text == "OK permanent" 39 | 40 | assert len(httpserver.oneshot_handlers) == 0 41 | 42 | 43 | def test_all_happy_path1(httpserver: HTTPServer): 44 | _setup_all(httpserver) 45 | 46 | # ordered must go first 47 | assert requests.get(httpserver.url_for("/ordered1")).text == "OK ordered1" 48 | assert requests.get(httpserver.url_for("/ordered2")).text == "OK ordered2" 49 | assert requests.get(httpserver.url_for("/permanent")).text == "OK permanent" 50 | assert requests.get(httpserver.url_for("/oneshot2")).text == "OK oneshot2" 51 | assert requests.get(httpserver.url_for("/oneshot1")).text == "OK oneshot1" 52 | assert requests.get(httpserver.url_for("/permanent")).text == "OK permanent" 53 | 54 | assert len(httpserver.oneshot_handlers) == 0 55 | assert len(httpserver.ordered_handlers) == 0 56 | 57 | 58 | def test_all_ordered_missing(httpserver: HTTPServer): 59 | _setup_all(httpserver) 60 | 61 | # ordered is missing so everything must fail 62 | # a.k.a. permanently fail 63 | 64 | requests.get(httpserver.url_for("/permanent")) 65 | with pytest.raises(AssertionError): 66 | httpserver.check_assertions() 67 | 68 | requests.get(httpserver.url_for("/oneshot2")) 69 | with pytest.raises(AssertionError): 70 | httpserver.check_assertions() 71 | 72 | requests.get(httpserver.url_for("/oneshot1")) 73 | with pytest.raises(AssertionError): 74 | httpserver.check_assertions() 75 | 76 | requests.get(httpserver.url_for("/permanent")) 77 | with pytest.raises(AssertionError): 78 | httpserver.check_assertions() 79 | 80 | # handlers must be still intact but as the ordered are failed 81 | # everything will fail 82 | assert len(httpserver.ordered_handlers) == 2 83 | assert len(httpserver.oneshot_handlers) == 2 84 | assert len(httpserver.handlers) == 1 85 | 86 | 87 | def test_repr(httpserver: HTTPServer): 88 | assert repr(httpserver) == f"" 89 | -------------------------------------------------------------------------------- /tests/test_oneshot.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pytest_httpserver import HTTPServer 4 | 5 | 6 | def test_oneshot(httpserver: HTTPServer): 7 | httpserver.expect_oneshot_request("/foobar").respond_with_data("OK foobar") 8 | httpserver.expect_oneshot_request("/foobaz").respond_with_data("OK foobaz") 9 | 10 | assert len(httpserver.oneshot_handlers) == 2 11 | 12 | # first requests should pass 13 | response = requests.get(httpserver.url_for("/foobaz")) 14 | httpserver.check_assertions() 15 | assert response.status_code == 200 16 | assert response.text == "OK foobaz" 17 | 18 | response = requests.get(httpserver.url_for("/foobar")) 19 | httpserver.check_assertions() 20 | assert response.status_code == 200 21 | assert response.text == "OK foobar" 22 | 23 | assert len(httpserver.oneshot_handlers) == 0 24 | 25 | # second requests should fail due to 'oneshot' type 26 | assert requests.get(httpserver.url_for("/foobar")).status_code == 500 27 | assert requests.get(httpserver.url_for("/foobaz")).status_code == 500 28 | 29 | 30 | def test_oneshot_any_method(httpserver: HTTPServer): 31 | for _ in range(5): 32 | httpserver.expect_oneshot_request("/foobar").respond_with_data("OK") 33 | 34 | response = requests.post(httpserver.url_for("/foobar")) 35 | assert response.text == "OK" 36 | assert response.status_code == 200 37 | 38 | response = requests.get(httpserver.url_for("/foobar")) 39 | assert response.text == "OK" 40 | assert response.status_code == 200 41 | 42 | response = requests.delete(httpserver.url_for("/foobar")) 43 | assert response.text == "OK" 44 | assert response.status_code == 200 45 | 46 | response = requests.put(httpserver.url_for("/foobar")) 47 | assert response.text == "OK" 48 | assert response.status_code == 200 49 | 50 | response = requests.patch(httpserver.url_for("/foobar")) 51 | assert response.text == "OK" 52 | assert response.status_code == 200 53 | 54 | assert len(httpserver.oneshot_handlers) == 0 55 | -------------------------------------------------------------------------------- /tests/test_ordered.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pytest_httpserver import HTTPServer 4 | 5 | 6 | def test_ordered_ok(httpserver: HTTPServer): 7 | httpserver.expect_ordered_request("/foobar").respond_with_data("OK foobar") 8 | httpserver.expect_ordered_request("/foobaz").respond_with_data("OK foobaz") 9 | 10 | assert len(httpserver.ordered_handlers) == 2 11 | 12 | # first requests should pass 13 | response = requests.get(httpserver.url_for("/foobar")) 14 | httpserver.check_assertions() 15 | assert response.status_code == 200 16 | assert response.text == "OK foobar" 17 | 18 | response = requests.get(httpserver.url_for("/foobaz")) 19 | httpserver.check_assertions() 20 | assert response.status_code == 200 21 | assert response.text == "OK foobaz" 22 | 23 | assert len(httpserver.ordered_handlers) == 0 24 | 25 | # second requests should fail due to 'oneshot' type 26 | assert requests.get(httpserver.url_for("/foobar")).status_code == 500 27 | assert requests.get(httpserver.url_for("/foobaz")).status_code == 500 28 | 29 | 30 | def test_ordered_invalid_order(httpserver: HTTPServer): 31 | httpserver.expect_ordered_request("/foobar").respond_with_data("OK foobar") 32 | httpserver.expect_ordered_request("/foobaz").respond_with_data("OK foobaz") 33 | 34 | assert len(httpserver.ordered_handlers) == 2 35 | 36 | # these would not pass as the order is different 37 | # this mark the whole thing 'permanently failed' so no further requests must pass 38 | response = requests.get(httpserver.url_for("/foobaz")) 39 | assert response.status_code == 500 40 | 41 | response = requests.get(httpserver.url_for("/foobar")) 42 | assert response.status_code == 500 43 | 44 | # as no ordered handlers are triggered yet, these must be intact.. 45 | assert len(httpserver.ordered_handlers) == 2 46 | -------------------------------------------------------------------------------- /tests/test_parse_qs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import urllib.parse 4 | 5 | import pytest 6 | 7 | parse_qsl_semicolon_cases = [ 8 | ("&", []), 9 | ("&&", []), 10 | ("&a=b", [("a", "b")]), 11 | ("a=a+b&b=b+c", [("a", "a b"), ("b", "b c")]), 12 | ("a=1&a=2", [("a", "1"), ("a", "2")]), 13 | ("a=", [("a", "")]), 14 | ("a=foo bar&b=bar foo", [("a", "foo bar"), ("b", "bar foo")]), 15 | ("a=foo%20bar&b=bar%20foo", [("a", "foo bar"), ("b", "bar foo")]), 16 | ("a=%20%21%22%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D", [("a", " !\"#$%&'()*+,/:;=?@[]")]), 17 | ] 18 | 19 | 20 | @pytest.mark.parametrize(("qs", "expected"), parse_qsl_semicolon_cases) 21 | def test_qsl(qs: str, expected: list[tuple[bytes, bytes]]): 22 | assert urllib.parse.parse_qsl(qs, keep_blank_values=True) == expected 23 | -------------------------------------------------------------------------------- /tests/test_permanent.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | from werkzeug import Response 4 | 5 | from pytest_httpserver import HTTPServer 6 | 7 | JSON_STRING = '{"foo": "bar"}' 8 | 9 | 10 | def test_expected_request_json(httpserver: HTTPServer): 11 | httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"}) 12 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 13 | 14 | 15 | def test_expected_request_data(httpserver: HTTPServer): 16 | httpserver.expect_request("/foobar").respond_with_data(JSON_STRING) 17 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 18 | 19 | 20 | def test_expected_request_handler(httpserver: HTTPServer): 21 | httpserver.expect_request("/foobar").respond_with_handler(lambda request: JSON_STRING) # type: ignore 22 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 23 | 24 | 25 | def test_expected_request_response(httpserver: HTTPServer): 26 | httpserver.expect_request("/foobar").respond_with_response(Response(JSON_STRING)) 27 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 28 | 29 | 30 | def test_expected_request_response_as_string(httpserver: HTTPServer): 31 | httpserver.expect_request("/foobar").respond_with_response(JSON_STRING) # type: ignore 32 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 33 | 34 | 35 | def test_request_post(httpserver: HTTPServer): 36 | httpserver.expect_request("/foobar", data='{"request": "example"}', method="POST").respond_with_data( 37 | "example_response" 38 | ) 39 | response = requests.post(httpserver.url_for("/foobar"), json={"request": "example"}) 40 | httpserver.check_assertions() 41 | assert response.text == "example_response" 42 | assert response.status_code == 200 43 | 44 | 45 | def test_request_post_case_insensitive_method(httpserver: HTTPServer): 46 | httpserver.expect_request("/foobar", data='{"request": "example"}', method="post").respond_with_data( 47 | "example_response" 48 | ) 49 | response = requests.post(httpserver.url_for("/foobar"), json={"request": "example"}) 50 | httpserver.check_assertions() 51 | assert response.text == "example_response" 52 | assert response.status_code == 200 53 | 54 | 55 | def test_request_any_method(httpserver: HTTPServer): 56 | httpserver.expect_request("/foobar").respond_with_data("OK") 57 | response = requests.post(httpserver.url_for("/foobar")) 58 | assert response.text == "OK" 59 | assert response.status_code == 200 60 | 61 | response = requests.delete(httpserver.url_for("/foobar")) 62 | assert response.text == "OK" 63 | assert response.status_code == 200 64 | 65 | response = requests.put(httpserver.url_for("/foobar")) 66 | assert response.text == "OK" 67 | assert response.status_code == 200 68 | 69 | response = requests.patch(httpserver.url_for("/foobar")) 70 | assert response.text == "OK" 71 | assert response.status_code == 200 72 | 73 | response = requests.get(httpserver.url_for("/foobar")) 74 | assert response.text == "OK" 75 | assert response.status_code == 200 76 | 77 | 78 | def test_unexpected_request(httpserver: HTTPServer): 79 | httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"}) 80 | requests.get(httpserver.url_for("/nonexists")) 81 | with pytest.raises(AssertionError): 82 | httpserver.check_assertions() 83 | 84 | 85 | def test_no_handler_status_code(httpserver: HTTPServer): 86 | httpserver.no_handler_status_code = 404 87 | assert requests.get(httpserver.url_for("/foobar")).status_code == 404 88 | 89 | 90 | def test_server_cleared_for_each_test(httpserver: HTTPServer): 91 | assert httpserver.log == [] 92 | assert httpserver.assertions == [] 93 | assert httpserver.ordered_handlers == [] 94 | assert httpserver.oneshot_handlers == [] 95 | assert httpserver.handlers == [] 96 | 97 | 98 | def test_response_handler_replaced(httpserver: HTTPServer): 99 | # https://github.com/csernazs/pytest-httpserver/issues/229 100 | handler = httpserver.expect_request("/foobar") 101 | handler.respond_with_data("FOO") 102 | response = requests.get(httpserver.url_for("/foobar")) 103 | assert response.text == "FOO" 104 | assert response.status_code == 200 105 | handler.respond_with_json({"foo": "bar"}) 106 | response = requests.get(httpserver.url_for("/foobar")) 107 | assert response.json() == {"foo": "bar"} 108 | assert response.status_code == 200 109 | 110 | 111 | def test_request_handler_repr(httpserver: HTTPServer): 112 | handler = httpserver.expect_request("/foo", method="POST") 113 | assert ( 114 | repr(handler) 115 | == ">" 116 | ) 117 | 118 | handler = httpserver.expect_request("/query", query_string={"a": "123"}) 119 | assert ( 120 | repr(handler) == ">" 122 | ) 123 | -------------------------------------------------------------------------------- /tests/test_port_changing.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from pytest_httpserver import HTTPServer 6 | from pytest_httpserver.pytest_plugin import get_httpserver_listen_address 7 | 8 | PORT_KEY = "PYTEST_HTTPSERVER_PORT" 9 | HOST_KEY = "PYTEST_HTTPSERVER_HOST" 10 | 11 | 12 | @pytest.fixture 13 | def tmpenv(): 14 | old_vars = {} 15 | for key in (HOST_KEY, PORT_KEY): 16 | old_vars[key] = os.environ.get(key) 17 | 18 | os.environ[HOST_KEY] = "5.5.5.5" 19 | os.environ[PORT_KEY] = "12345" 20 | 21 | yield 22 | 23 | for key, value in old_vars.items(): 24 | if value: 25 | os.environ[key] = value 26 | else: 27 | del os.environ[key] 28 | 29 | 30 | @pytest.mark.skipif(HOST_KEY not in os.environ, reason="requires {} environment variable".format(HOST_KEY)) 31 | def test_host_changing_by_environment(httpserver: HTTPServer): 32 | assert httpserver.host == os.environ[HOST_KEY] 33 | 34 | 35 | @pytest.mark.skipif(PORT_KEY not in os.environ, reason="requires {} environment variable".format(PORT_KEY)) 36 | def test_port_changing_by_environment(httpserver: HTTPServer): 37 | assert httpserver.port == int(os.environ[PORT_KEY]) 38 | 39 | 40 | def test_get_httpserver_listen_address_with_env(tmpenv): # noqa: ARG001 41 | address = get_httpserver_listen_address() 42 | assert address[0] == "5.5.5.5" 43 | assert address[1] == 12345 44 | -------------------------------------------------------------------------------- /tests/test_querymatcher.py: -------------------------------------------------------------------------------- 1 | from werkzeug.datastructures import MultiDict 2 | 3 | from pytest_httpserver.httpserver import BooleanQueryMatcher 4 | from pytest_httpserver.httpserver import MappingQueryMatcher 5 | from pytest_httpserver.httpserver import StringQueryMatcher 6 | 7 | 8 | def assert_match(qm, query_string): 9 | values = qm.get_comparing_values(query_string) 10 | assert values[0] == values[1] 11 | 12 | 13 | def assert_not_match(qm, query_string): 14 | values = qm.get_comparing_values(query_string) 15 | assert values[0] != values[1] 16 | 17 | 18 | def test_qm_string(): 19 | qm = StringQueryMatcher("k1=v1&k2=v2") 20 | assert_match(qm, b"k1=v1&k2=v2") 21 | assert_not_match(qm, b"k2=v2&k1=v1") 22 | 23 | 24 | def test_qm_bytes(): 25 | qm = StringQueryMatcher(b"k1=v1&k2=v2") 26 | assert_match(qm, b"k1=v1&k2=v2") 27 | assert_not_match(qm, b"k2=v2&k1=v1") 28 | 29 | 30 | def test_qm_boolean(): 31 | qm = BooleanQueryMatcher(result=True) 32 | assert_match(qm, b"k1=v1") 33 | 34 | 35 | def test_qm_mapping_string(): 36 | qm = MappingQueryMatcher({"k1": "v1"}) 37 | assert_match(qm, b"k1=v1") 38 | 39 | 40 | def test_qm_mapping_unordered(): 41 | qm = MappingQueryMatcher({"k1": "v1", "k2": "v2"}) 42 | assert_match(qm, b"k1=v1&k2=v2") 43 | assert_match(qm, b"k2=v2&k1=v1") 44 | 45 | 46 | def test_qm_mapping_first_value(): 47 | qm = MappingQueryMatcher({"k1": "v1"}) 48 | assert_match(qm, b"k1=v1&k1=v2") 49 | 50 | qm = MappingQueryMatcher({"k1": "v2"}) 51 | assert_match(qm, b"k1=v2&k1=v1") 52 | 53 | 54 | def test_qm_mapping_multiple_values(): 55 | md = MultiDict([("k1", "v1"), ("k1", "v2")]) 56 | qm = MappingQueryMatcher(md) 57 | assert_match(qm, b"k1=v1&k1=v2") 58 | -------------------------------------------------------------------------------- /tests/test_querystring.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pytest_httpserver import HTTPServer 4 | 5 | 6 | def test_querystring_str(httpserver: HTTPServer): 7 | httpserver.expect_request("/foobar", query_string="foo=bar", method="GET").respond_with_data("example_response") 8 | response = requests.get(httpserver.url_for("/foobar?foo=bar")) 9 | httpserver.check_assertions() 10 | assert response.text == "example_response" 11 | assert response.status_code == 200 12 | 13 | 14 | def test_querystring_bytes(httpserver: HTTPServer): 15 | httpserver.expect_request("/foobar", query_string=b"foo=bar", method="GET").respond_with_data("example_response") 16 | response = requests.get(httpserver.url_for("/foobar?foo=bar")) 17 | httpserver.check_assertions() 18 | assert response.text == "example_response" 19 | assert response.status_code == 200 20 | 21 | 22 | def test_querystring_dict(httpserver: HTTPServer): 23 | httpserver.expect_request("/foobar", query_string={"k1": "v1", "k2": "v2"}, method="GET").respond_with_data( 24 | "example_response" 25 | ) 26 | response = requests.get(httpserver.url_for("/foobar?k1=v1&k2=v2")) 27 | httpserver.check_assertions() 28 | assert response.text == "example_response" 29 | assert response.status_code == 200 30 | 31 | response = requests.get(httpserver.url_for("/foobar?k2=v2&k1=v1")) 32 | httpserver.check_assertions() 33 | assert response.text == "example_response" 34 | assert response.status_code == 200 35 | -------------------------------------------------------------------------------- /tests/test_release.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import email 4 | import re 5 | import shutil 6 | import subprocess 7 | import tarfile 8 | import zipfile 9 | from dataclasses import dataclass 10 | from pathlib import Path 11 | from typing import TYPE_CHECKING 12 | 13 | import pytest 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterable 17 | 18 | try: 19 | import tomllib 20 | except ImportError: 21 | # Unfortunately mypy cannot handle this try/expect pattern, and "type: ignore" 22 | # is the simplest work-around. See: https://github.com/python/mypy/issues/1153 23 | import tomli as tomllib # type: ignore 24 | 25 | # TODO: skip if poetry is not available or add mark to test it explicitly 26 | 27 | 28 | pytestmark = pytest.mark.release 29 | 30 | NAME = "pytest-httpserver" 31 | NAME_UNDERSCORE = NAME.replace("-", "_") 32 | PY_MAX_VERSION = (3, 13) 33 | 34 | 35 | @pytest.fixture(scope="session") 36 | def pyproject_path() -> Path: 37 | return Path("pyproject.toml") 38 | 39 | 40 | @pytest.fixture(scope="session") 41 | def pyproject(pyproject_path: Path): 42 | assert pyproject_path.is_file() 43 | with pyproject_path.open("rb") as infile: 44 | pyproject = tomllib.load(infile) 45 | return pyproject 46 | 47 | 48 | class Wheel: 49 | def __init__(self, path: Path): 50 | self.path = path 51 | 52 | @property 53 | def wheel_out_dir(self) -> Path: 54 | return self.path.parent.joinpath("wheel") 55 | 56 | def extract(self): 57 | with zipfile.ZipFile(self.path) as zf: 58 | zf.extractall(self.wheel_out_dir) # noqa: S202 59 | 60 | def get_meta(self, version: str, name: str = NAME_UNDERSCORE) -> email.message.Message: 61 | metadata_path = self.wheel_out_dir.joinpath(f"{name}-{version}.dist-info", "METADATA") 62 | with metadata_path.open() as metadata_file: 63 | msg = email.message_from_file(metadata_file) 64 | 65 | return msg 66 | 67 | 68 | class Sdist: 69 | def __init__(self, path: Path): 70 | self.path = path 71 | 72 | @property 73 | def sdist_out_dir(self) -> Path: 74 | return self.path.parent.joinpath("sdist") 75 | 76 | def extract(self): 77 | with tarfile.open(self.path, mode="r:gz") as tf: 78 | tf.extractall(self.sdist_out_dir) # noqa: S202 79 | 80 | 81 | @dataclass 82 | class Build: 83 | wheel: Wheel 84 | sdist: Sdist 85 | 86 | def extract(self): 87 | self.wheel.extract() 88 | self.sdist.extract() 89 | 90 | 91 | @pytest.fixture(scope="session") 92 | def build() -> Iterable[Build]: 93 | dist_path = Path("dist").resolve() 94 | if dist_path.is_dir(): 95 | shutil.rmtree(dist_path) 96 | 97 | try: 98 | subprocess.run(["poetry", "build"], check=True) 99 | assert dist_path.is_dir() 100 | wheels = list(dist_path.glob("*.whl")) 101 | sdists = list(dist_path.glob("*.tar.gz")) 102 | assert len(wheels) == 1 103 | assert len(sdists) == 1 104 | build = Build(wheel=Wheel(wheels[0]), sdist=Sdist(sdists[0])) 105 | build.extract() 106 | yield build 107 | 108 | finally: 109 | shutil.rmtree(dist_path) 110 | 111 | 112 | @pytest.fixture(scope="session") 113 | def version(pyproject) -> str: 114 | return pyproject["tool"]["poetry"]["version"] 115 | 116 | 117 | def version_to_tuple(version: str) -> tuple: 118 | return tuple([int(x) for x in version.split(".")]) 119 | 120 | 121 | def test_no_duplicate_classifiers(build: Build, pyproject): 122 | pyproject_meta = pyproject["tool"]["poetry"] 123 | wheel_meta = build.wheel.get_meta(version=pyproject_meta["version"]) 124 | classifiers = wheel_meta.get_all("Classifier") 125 | assert classifiers is not None 126 | 127 | sorted_classifiers = sorted(classifiers) 128 | unique_classifiers = sorted(set(classifiers)) 129 | 130 | assert sorted_classifiers == unique_classifiers 131 | 132 | 133 | def test_python_version(build: Build, pyproject): 134 | pyproject_meta = pyproject["tool"]["poetry"] 135 | wheel_meta = build.wheel.get_meta(version=pyproject_meta["version"]) 136 | python_dependency = pyproject_meta["dependencies"]["python"] 137 | m = re.match(r">=(\d+\.\d+)", python_dependency) 138 | if m: 139 | min_version, *_ = m.groups() 140 | else: 141 | raise ValueError(python_dependency) 142 | 143 | min_version_tuple = version_to_tuple(min_version) 144 | 145 | classifiers = wheel_meta.get_all("Classifier") 146 | assert classifiers is not None 147 | 148 | for classifier in classifiers: 149 | if classifier.startswith("Programming Language :: Python ::"): 150 | version_tuple = version_to_tuple(classifier.split("::")[-1].strip()) 151 | if len(version_tuple) > 1: 152 | assert version_tuple >= min_version_tuple 153 | assert version_tuple <= PY_MAX_VERSION 154 | 155 | 156 | def test_wheel_no_extra_contents(build: Build, version: str): 157 | wheel_dir = build.wheel.wheel_out_dir 158 | wheel_contents = list(wheel_dir.iterdir()) 159 | assert len(wheel_contents) == 2 160 | assert wheel_dir.joinpath(NAME_UNDERSCORE).is_dir() 161 | assert wheel_dir.joinpath(f"{NAME_UNDERSCORE}-{version}.dist-info").is_dir() 162 | 163 | package_contents = {path.name for path in wheel_dir.joinpath(NAME_UNDERSCORE).iterdir()} 164 | assert package_contents == { 165 | "__init__.py", 166 | "blocking_httpserver.py", 167 | "hooks.py", 168 | "httpserver.py", 169 | "py.typed", 170 | "pytest_plugin.py", 171 | } 172 | 173 | 174 | def test_sdist_contents(build: Build, version: str): 175 | sdist_base = build.sdist.sdist_out_dir.joinpath(f"pytest_httpserver-{version}") 176 | 177 | subdir_contents = { 178 | ".": { 179 | "CHANGES.rst", 180 | "CONTRIBUTION.md", 181 | "doc", 182 | "example_pytest.py", 183 | "example.py", 184 | "LICENSE", 185 | "PKG-INFO", 186 | "pyproject.toml", 187 | "pytest_httpserver", 188 | "README.md", 189 | "tests", 190 | }, 191 | "doc": { 192 | "_static", 193 | "api.rst", 194 | "background.rst", 195 | "changes.rst", 196 | "conf.py", 197 | "fixtures.rst", 198 | "guide.rst", 199 | "howto.rst", 200 | "index.rst", 201 | "Makefile", 202 | "patch.py", 203 | "tutorial.rst", 204 | "upgrade.rst", 205 | }, 206 | "pytest_httpserver": { 207 | "__init__.py", 208 | "blocking_httpserver.py", 209 | "hooks.py", 210 | "httpserver.py", 211 | "py.typed", 212 | "pytest_plugin.py", 213 | }, 214 | "tests": { 215 | "assets", 216 | "conftest.py", 217 | "examples", 218 | "test_blocking_httpserver.py", 219 | "test_handler_errors.py", 220 | "test_headers.py", 221 | "test_hooks.py", 222 | "test_ip_protocols.py", 223 | "test_json_matcher.py", 224 | "test_log_leak.py", 225 | "test_log_querying.py", 226 | "test_mixed.py", 227 | "test_oneshot.py", 228 | "test_ordered.py", 229 | "test_permanent.py", 230 | "test_parse_qs.py", 231 | "test_port_changing.py", 232 | "test_querymatcher.py", 233 | "test_querystring.py", 234 | "test_release.py", 235 | "test_ssl.py", 236 | "test_thread_type.py", 237 | "test_threaded.py", 238 | "test_urimatch.py", 239 | "test_wait.py", 240 | "test_with_statement.py", 241 | "test_matcher.py", 242 | }, 243 | } 244 | 245 | for subdir, subdir_content in subdir_contents.items(): 246 | contents = {path.name for path in sdist_base.joinpath(subdir).iterdir()} 247 | assert contents == subdir_content 248 | 249 | 250 | def test_poetry_check(): 251 | subprocess.run(["poetry", "check"], check=True) 252 | -------------------------------------------------------------------------------- /tests/test_ssl.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ssl 3 | from os.path import join as pjoin 4 | 5 | import pytest 6 | import requests 7 | 8 | from pytest_httpserver import HTTPServer 9 | 10 | pytestmark = pytest.mark.ssl 11 | 12 | test_dir = os.path.dirname(os.path.realpath(__file__)) 13 | assets_dir = pjoin(test_dir, "assets") 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | def httpserver_ssl_context(): 18 | protocol = None 19 | for name in ("PROTOCOL_TLS_SERVER", "PROTOCOL_TLS", "PROTOCOL_TLSv1_2"): 20 | if hasattr(ssl, name): 21 | protocol = getattr(ssl, name) 22 | break 23 | 24 | assert protocol is not None, "Unable to obtain TLS protocol" 25 | 26 | return ssl.SSLContext(protocol) 27 | 28 | 29 | def test_ssl(httpserver: HTTPServer): 30 | server_crt = pjoin(assets_dir, "server.crt") 31 | server_key = pjoin(assets_dir, "server.key") 32 | root_ca = pjoin(assets_dir, "rootCA.crt") 33 | 34 | assert ( 35 | httpserver.ssl_context is not None 36 | ), "SSLContext not set. The session was probably started with a test that did not define an SSLContext." 37 | 38 | httpserver.ssl_context.load_cert_chain(server_crt, server_key) 39 | httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"}) 40 | 41 | assert httpserver.is_running() 42 | 43 | assert httpserver.url_for("/").startswith("https://") 44 | 45 | # ensure we are using "localhost" and not "127.0.0.1" to pass cert verification 46 | url = f"https://localhost:{httpserver.port}/foobar" 47 | 48 | assert requests.get(url, verify=root_ca).json() == {"foo": "bar"} 49 | -------------------------------------------------------------------------------- /tests/test_thread_type.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import threading 4 | from typing import TYPE_CHECKING 5 | 6 | import requests 7 | from werkzeug import Response 8 | 9 | if TYPE_CHECKING: 10 | from werkzeug import Request 11 | 12 | from pytest_httpserver import HTTPServer 13 | 14 | 15 | def test_server_thread_is_daemon(httpserver: HTTPServer): 16 | def handler(_request: Request): 17 | return Response(f"{threading.current_thread().daemon}") 18 | 19 | httpserver.expect_request("/foo").respond_with_handler(handler) 20 | 21 | assert requests.get(httpserver.url_for("/foo")).text == "True" 22 | -------------------------------------------------------------------------------- /tests/test_threaded.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | import threading 3 | import time 4 | from collections.abc import Iterable 5 | 6 | import pytest 7 | from werkzeug import Request 8 | from werkzeug import Response 9 | 10 | from pytest_httpserver import HTTPServer 11 | 12 | 13 | @pytest.fixture 14 | def threaded() -> Iterable[HTTPServer]: 15 | server = HTTPServer(threaded=True) 16 | server.start() 17 | yield server 18 | server.clear() 19 | if server.is_running(): 20 | server.stop() 21 | 22 | 23 | def test_threaded(threaded: HTTPServer): 24 | sleep_time = 0.5 25 | 26 | def handler(_request: Request): 27 | # allow some time to the client to have multiple pending request 28 | # handlers running in parallel 29 | time.sleep(sleep_time) 30 | 31 | # send back thread id 32 | return Response(f"{threading.get_ident()}") 33 | 34 | threaded.expect_request("/foo").respond_with_handler(handler) 35 | 36 | number_of_connections = 5 37 | conns = [http.client.HTTPConnection(threaded.host, threaded.port) for _ in range(number_of_connections)] 38 | 39 | for conn in conns: 40 | conn.request("GET", "/foo", headers={"Host": threaded.host}) 41 | 42 | thread_ids: list[int] = [] 43 | for conn in conns: 44 | response = conn.getresponse() 45 | 46 | assert response.status == 200 47 | thread_ids.append(int(response.read())) 48 | 49 | for conn in conns: 50 | conn.close() 51 | 52 | assert len(thread_ids) == len(set(thread_ids)), "thread ids returned should be unique" 53 | -------------------------------------------------------------------------------- /tests/test_urimatch.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import requests 4 | 5 | from pytest_httpserver import HTTPServer 6 | from pytest_httpserver import URIPattern 7 | 8 | 9 | class PrefixMatch(URIPattern): 10 | def __init__(self, prefix: str): 11 | self.prefix = prefix 12 | 13 | def match(self, uri): 14 | return uri.startswith(self.prefix) 15 | 16 | 17 | class PrefixMatchEq: 18 | def __init__(self, prefix: str): 19 | self.prefix = prefix 20 | 21 | def __eq__(self, uri): 22 | return uri.startswith(self.prefix) 23 | 24 | 25 | def test_uripattern_object(httpserver: HTTPServer): 26 | httpserver.expect_request(PrefixMatch("/foo")).respond_with_json({"foo": "bar"}) 27 | assert requests.get(httpserver.url_for("/foo")).json() == {"foo": "bar"} 28 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 29 | assert requests.get(httpserver.url_for("/foobaz")).json() == {"foo": "bar"} 30 | 31 | assert requests.get(httpserver.url_for("/barfoo")).status_code == 500 32 | 33 | assert len(httpserver.assertions) == 1 34 | 35 | 36 | def test_regexp(httpserver: HTTPServer): 37 | httpserver.expect_request(re.compile(r"/foo/\d+/bar/")).respond_with_json({"foo": "bar"}) 38 | assert requests.get(httpserver.url_for("/foo/123/bar/")).json() == {"foo": "bar"} 39 | assert requests.get(httpserver.url_for("/foo/9999/bar/")).json() == {"foo": "bar"} 40 | 41 | assert requests.get(httpserver.url_for("/foo/bar/")).status_code == 500 42 | 43 | assert len(httpserver.assertions) == 1 44 | 45 | 46 | def test_object_with_eq(httpserver: HTTPServer): 47 | httpserver.expect_request(PrefixMatchEq("/foo")).respond_with_json({"foo": "bar"}) # type: ignore 48 | assert requests.get(httpserver.url_for("/foo")).json() == {"foo": "bar"} 49 | assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"} 50 | assert requests.get(httpserver.url_for("/foobaz")).json() == {"foo": "bar"} 51 | 52 | assert requests.get(httpserver.url_for("/barfoo")).status_code == 500 53 | 54 | assert len(httpserver.assertions) == 1 55 | -------------------------------------------------------------------------------- /tests/test_wait.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from pytest import approx 3 | from pytest import raises 4 | 5 | from pytest_httpserver import HTTPServer 6 | 7 | 8 | def test_wait_success(httpserver: HTTPServer): 9 | waiting_timeout = 0.1 10 | 11 | with httpserver.wait(stop_on_nohandler=False, timeout=waiting_timeout) as waiting: 12 | requests.get(httpserver.url_for("/foobar")) 13 | httpserver.expect_oneshot_request("/foobar").respond_with_data("OK foobar") 14 | requests.get(httpserver.url_for("/foobar")) 15 | assert waiting.result 16 | 17 | httpserver.expect_oneshot_request("/foobar").respond_with_data("OK foobar") 18 | httpserver.expect_oneshot_request("/foobaz").respond_with_data("OK foobaz") 19 | with httpserver.wait(timeout=waiting_timeout) as waiting: 20 | requests.get(httpserver.url_for("/foobar")) 21 | requests.get(httpserver.url_for("/foobaz")) 22 | assert waiting.result 23 | 24 | 25 | def test_wait_unexpected_request(httpserver: HTTPServer): 26 | def make_unexpected_request_and_wait() -> None: 27 | with raises(AssertionError) as error: 28 | waiting_timeout = 0.1 29 | with httpserver.wait(raise_assertions=True, stop_on_nohandler=True, timeout=waiting_timeout) as waiting: 30 | requests.get(httpserver.url_for("/foobaz")) 31 | assert not waiting.result 32 | no_handler_text = "No handler found for request" 33 | assert no_handler_text in str(error.value) 34 | 35 | make_unexpected_request_and_wait() 36 | 37 | httpserver.expect_ordered_request("/foobar").respond_with_data("OK foobar") 38 | httpserver.expect_ordered_request("/foobaz").respond_with_data("OK foobaz") 39 | make_unexpected_request_and_wait() 40 | 41 | 42 | def test_wait_timeout(httpserver: HTTPServer): 43 | httpserver.expect_oneshot_request("/foobar").respond_with_data("OK foobar") 44 | httpserver.expect_oneshot_request("/foobaz").respond_with_data("OK foobaz") 45 | waiting_timeout = 1 46 | with raises(AssertionError) as error: 47 | with httpserver.wait(raise_assertions=True, timeout=waiting_timeout) as waiting: 48 | requests.get(httpserver.url_for("/foobar")) 49 | assert not waiting.result 50 | waiting_time_error = 0.1 51 | assert waiting.elapsed_time == approx(waiting_timeout, abs=waiting_time_error) 52 | assert "Wait timeout occurred, but some handlers left" in str(error.value) 53 | 54 | 55 | def test_wait_raise_assertion_false(httpserver: HTTPServer): 56 | waiting_timeout = 0.1 57 | 58 | try: 59 | with httpserver.wait(raise_assertions=False, stop_on_nohandler=True, timeout=waiting_timeout) as waiting: 60 | requests.get(httpserver.url_for("/foobaz")) 61 | except AssertionError as error: 62 | raise AssertionError("raise_assertions was set to False, but assertion was raised: {}".format(error)) 63 | assert not waiting.result 64 | 65 | try: 66 | with httpserver.wait(raise_assertions=False, stop_on_nohandler=True, timeout=waiting_timeout) as waiting: 67 | pass 68 | except AssertionError as error: 69 | raise AssertionError("raise_assertions was set to False, but assertion was raised: {}".format(error)) 70 | assert not waiting.result 71 | waiting_time_error = 0.1 72 | assert waiting.elapsed_time == approx(waiting_timeout, abs=waiting_time_error) 73 | -------------------------------------------------------------------------------- /tests/test_with_statement.py: -------------------------------------------------------------------------------- 1 | from pytest_httpserver import HTTPServer 2 | 3 | 4 | def test_server_with_statement(): 5 | with HTTPServer(port=4001): 6 | pass 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py38 4 | py39 5 | py310 6 | py311 7 | 8 | base_python = py311 9 | 10 | isolated_build = true 11 | 12 | [testenv] 13 | whitelist_externals = poetry 14 | commands = 15 | poetry install -v --with test 16 | poetry run pytest -vv 17 | poetry run pytest -vv --ssl 18 | -------------------------------------------------------------------------------- /tox.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | let 4 | unstable = import { config = { allowUnfree = true; }; }; 5 | toxPython = p: p.withPackages ( p: [ p.pip ] ); 6 | basePython = p: (p.withPackages ( p: [ p.virtualenv p.pip p.tox ] )); 7 | 8 | in pkgs.mkShell { 9 | buildInputs = with pkgs; [ 10 | (basePython python311) 11 | (toxPython python310) 12 | (toxPython python39) 13 | (toxPython python38) 14 | 15 | bashInteractive 16 | ]; 17 | } 18 | --------------------------------------------------------------------------------