├── .flake8 ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── DESCRIPTION.rst ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── pytest_httpbin ├── __init__.py ├── certs.py ├── certs │ ├── README.md │ ├── client.pem │ ├── server.key │ └── server.pem ├── plugin.py ├── serve.py └── version.py ├── release.py ├── runtests.sh └── tests ├── conftest.py ├── test_httpbin.py ├── test_server.py └── util.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | disable-noqa = True 3 | max-line-length = 88 4 | extend-ignore = 5 | E203 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | tags: 7 | - v* 8 | pull_request: 9 | 10 | jobs: 11 | tox: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: [3.8, 3.9, '3.10', 3.11, 3.12, 3.13, pypy-3.10] 17 | os: [macOS-latest, ubuntu-latest, windows-latest] 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Set Up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | cache: pip 28 | allow-prereleases: true 29 | cache-dependency-path: | 30 | pyproject.toml 31 | setup.cfg 32 | setup.py 33 | 34 | - name: Install 35 | run: | 36 | pip install tox 37 | 38 | - name: tox 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | run: tox -e py,release 42 | 43 | - name: upload dist 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: ${{ matrix.os }}_${{ matrix.python-version}}_dist 47 | path: dist 48 | 49 | all-successful: 50 | # https://github.community/t/is-it-possible-to-require-all-github-actions-tasks-to-pass-without-enumerating-them/117957/4?u=graingert 51 | runs-on: ubuntu-latest 52 | needs: [tox] 53 | permissions: 54 | id-token: write 55 | steps: 56 | - name: Download dists for PyPI 57 | uses: actions/download-artifact@v4 58 | with: 59 | name: ubuntu-latest_3.11_dist 60 | path: dist 61 | 62 | - name: Display structure of donwloaded files 63 | run: ls -R 64 | 65 | - name: Publish package 66 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 67 | uses: pypa/gh-action-pypi-publish@release/v1 68 | 69 | - name: note that all tests succeeded 70 | run: echo "🎉" 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Backup files 2 | *.~ 3 | *.swp 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | bin/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # Installer logs 29 | pip-log.txt 30 | pip-delete-this-directory.txt 31 | 32 | # Unit test / coverage reports 33 | .tox/ 34 | .coverage 35 | .cache 36 | nosetests.xml 37 | coverage.xml 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Sphinx documentation 43 | docs/_build/ 44 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.4.0 4 | hooks: 5 | - id: pyupgrade 6 | args: ['--py38-plus'] 7 | 8 | - repo: https://github.com/psf/black 9 | rev: 23.3.0 10 | hooks: 11 | - id: black 12 | args: ['--target-version', 'py38'] 13 | 14 | - repo: https://github.com/pycqa/isort 15 | rev: 5.12.0 16 | hooks: 17 | - id: isort 18 | 19 | - repo: https://github.com/pre-commit/pygrep-hooks 20 | rev: v1.10.0 21 | hooks: 22 | - id: python-check-blanket-noqa 23 | 24 | - repo: https://github.com/pre-commit/pre-commit-hooks 25 | rev: v4.4.0 26 | hooks: 27 | - id: check-merge-conflict 28 | - id: check-toml 29 | - id: check-yaml 30 | - id: mixed-line-ending 31 | 32 | - repo: https://github.com/pre-commit/mirrors-prettier 33 | rev: v3.0.0-alpha.9-for-vscode 34 | hooks: 35 | - id: prettier 36 | args: [--prose-wrap=always, --print-width=88] 37 | 38 | - repo: https://github.com/myint/autoflake 39 | rev: v2.1.1 40 | hooks: 41 | - id: autoflake 42 | args: 43 | - --in-place 44 | - --remove-all-unused-imports 45 | - --expand-star-imports 46 | - --remove-duplicate-keys 47 | - --remove-unused-variables 48 | 49 | - repo: https://github.com/pycqa/flake8 50 | rev: 6.0.0 51 | hooks: 52 | - id: flake8 53 | additional_dependencies: [flake8-2020] 54 | -------------------------------------------------------------------------------- /DESCRIPTION.rst: -------------------------------------------------------------------------------- 1 | pytest-httpbin 2 | ============== 3 | 4 | httpbin is an amazing web service for testing HTTP libraries. It has several 5 | great endpoints that can test pretty much everything you need in a HTTP 6 | library. The only problem is: maybe you don't want to wait for your tests to 7 | travel across the Internet and back to make assertions against a remote web 8 | service. 9 | 10 | Enter pytest-httpbin. Pytest-httpbin creates a pytest "fixture" that is 11 | dependency-injected into your tests. It automatically starts up a HTTP server 12 | in a separate thread running httpbin and provides your test with the URL in the 13 | fixture. Check out this example: 14 | 15 | .. code-block:: python 16 | 17 | def test_that_my_library_works_kinda_ok(httpbin): 18 | assert requests.get(httpbin.url + '/get/').status_code == 200 19 | 20 | This replaces a test that might have looked like this before: 21 | 22 | .. code-block:: python 23 | 24 | def test_that_my_library_works_kinda_ok(): 25 | assert requests.get('http://httpbin.org/get').status_code == 200 26 | 27 | pytest-httpbin also supports https and includes its own CA cert you can use. 28 | Check out `the full documentation`_ on the github page. 29 | 30 | .. _the full documentation: https://github.com/kevin1024/pytest-httpbin 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2023 Kevin McCarthy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # If using Python 2.6 or less, then have to include package data, even though 2 | # it's already declared in setup.py 3 | include pytest_httpbin/certs/* 4 | include DESCRIPTION.rst 5 | recursive-include tests *.py 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-httpbin 2 | 3 | [![Build Status](https://github.com/kevin1024/pytest-httpbin/actions/workflows/main.yaml/badge.svg)](https://github.com/kevin1024/pytest-httpbin/actions/workflows/main.yaml) 4 | 5 | [httpbin](https://httpbin.org/) is an amazing web service for testing HTTP libraries. It 6 | has several great endpoints that can test pretty much everything you need in a HTTP 7 | library. The only problem is: maybe you don't want to wait for your tests to travel 8 | across the Internet and back to make assertions against a remote web service (speed), 9 | and maybe you want to work offline (convenience). 10 | 11 | Enter **pytest-httpbin**. Pytest-httpbin creates a 12 | [pytest fixture](https://pytest.org/latest/fixture.html) that is dependency-injected 13 | into your tests. It automatically starts up a HTTP server in a separate thread running 14 | httpbin and provides your test with the URL in the fixture. Check out this example: 15 | 16 | ```python 17 | def test_that_my_library_works_kinda_ok(httpbin): 18 | assert requests.get(httpbin.url + '/get').status_code == 200 19 | ``` 20 | 21 | This replaces a test that might have looked like this before: 22 | 23 | ```python 24 | def test_that_my_library_works_kinda_ok(): 25 | assert requests.get('http://httpbin.org/get').status_code == 200 26 | ``` 27 | 28 | If you're making a lot of requests to httpbin, it can radically speed up your tests. 29 | 30 | ![demo](http://i.imgur.com/heNOQLP.gif) 31 | 32 | # HTTPS support 33 | 34 | pytest-httpbin also supports HTTPS: 35 | 36 | ```python 37 | def test_that_my_library_works_kinda_ok(httpbin_secure): 38 | assert requests.get(httpbin_secure.url + '/get/').status_code == 200 39 | ``` 40 | 41 | It's actually starting 2 web servers in separate threads in the background: one HTTP and 42 | one HTTPS. The servers are started on a random port (see below for fixed port support), 43 | on the loopback interface on your machine. Pytest-httpbin includes a self-signed 44 | certificate. If your library verifies certificates against a CA (and it should), you'll 45 | have to add the CA from pytest-httpbin. The path to the pytest-httpbin CA bundle can by 46 | found like this `python -m pytest_httpbin.certs`. 47 | 48 | For example in requests, you can set the `REQUESTS_CA_BUNDLE` python path. You can run 49 | your tests like this: 50 | 51 | ```bash 52 | REQUESTS_CA_BUNDLE=`python -m pytest_httpbin.certs` py.test tests/ 53 | ``` 54 | 55 | # API of the injected object 56 | 57 | The injected object has the following attributes: 58 | 59 | - url 60 | - port 61 | - host 62 | 63 | and the following methods: 64 | 65 | - join(string): Returns the results of calling `urlparse.urljoin` with the url from the 66 | injected server automatically applied as the first argument. You supply the second 67 | argument 68 | 69 | Also, I defined `__add__` on the object to append to `httpbin.url`. This means you can 70 | do stuff like `httpbin + '/get'` instead of `httpbin.url + '/get'`. 71 | 72 | ## Testing both HTTP and HTTPS endpoints with one test 73 | 74 | If you ever find yourself needing to test both the http and https version of and 75 | endpoint, you can use the `httpbin_both` funcarg like this: 76 | 77 | ```python 78 | def test_that_my_library_works_kinda_ok(httpbin_both): 79 | assert requests.get(httpbin_both.url + '/get/').status_code == 200 80 | ``` 81 | 82 | Through the magic of pytest parametrization, this function will actually execute twice: 83 | once with an http url and once with an https url. 84 | 85 | ## Using pytest-httpbin with unittest-style test cases 86 | 87 | I have provided 2 additional fixtures to make testing with class-based tests easier. I 88 | have also provided a couple decorators that provide some syntactic sugar around the 89 | pytest method of adding the fixtures to class-based tests. Just add the 90 | `use_class_based_httpbin` and/or `use_class_based_httpbin_secure` class decorators to 91 | your class, and then you can access httpbin using self.httpbin and self.httpbin_secure. 92 | 93 | ```python 94 | import pytest_httpbin 95 | 96 | @pytest_httpbin.use_class_based_httpbin 97 | @pytest_httpbin.use_class_based_httpbin_secure 98 | class TestClassBassedTests(unittest.TestCase): 99 | def test_http(self): 100 | assert requests.get(self.httpbin.url + '/get').response 101 | 102 | def test_http_secure(self): 103 | assert requests.get(self.httpbin_secure.url + '/get').response 104 | ``` 105 | 106 | ## Running the server on fixed port 107 | 108 | Sometimes a randomized port can be a problem. Worry not, you can fix the port number to 109 | a desired value with the `HTTPBIN_HTTP_PORT` and `HTTPBIN_HTTPS_PORT` environment 110 | variables. If those are defined during pytest plugins are loaded, `httbin` and 111 | `httpbin_secure` fixtures will run on given ports. You can run your tests like this: 112 | 113 | ```bash 114 | HTTPBIN_HTTP_PORT=8080 HTTPBIN_HTTPS_PORT=8443 py.test tests/ 115 | ``` 116 | 117 | ## Installation 118 | 119 | [![PyPI Version](https://img.shields.io/pypi/v/pytest-httpbin.svg)](https://pypi.org/project/pytest-httpbin/) 120 | [![Supported Versions](https://img.shields.io/pypi/pyversions/pytest-httpbin.svg)](https://pypi.org/project/pytest-httpbin/) 121 | 122 | To install from [PyPI](https://pypi.org/project/pytest-httpbin/), all you need to do is 123 | this: 124 | 125 | ```bash 126 | pip install pytest-httpbin 127 | ``` 128 | 129 | and your tests executed by pytest all will have access to the `httpbin` and 130 | `httpbin_secure` funcargs. Cool right? 131 | 132 | ## Support and dependencies 133 | 134 | pytest-httpbin supports Python 3.8+, and pypy. It will automatically 135 | install httpbin and flask when you install it from PyPI. 136 | 137 | [httpbin](https://github.com/postmanlabs/httpbin) itself does not support python 2.6 as 138 | of version 0.6.0, when the Flask-common dependency was added. If you need python 2.6 139 | support pin the httpbin version to 0.5.0 140 | 141 | ## Running the pytest-httpbin test suite 142 | 143 | If you want to run pytest-httpbin's test suite, you'll need to install requests and 144 | pytest, and then use the ./runtests.sh script. 145 | 146 | ```bash 147 | pip install pytest 148 | ./runtests.sh 149 | ``` 150 | 151 | Also, you can use tox to run the tests on all supported python versions: 152 | 153 | ```bash 154 | pip install tox 155 | tox 156 | ``` 157 | 158 | ## Changelog 159 | 160 | - 2.1.0 161 | - Drop support for Python 3.7 (#85) 162 | - Test against PyPy 3.10 (#77) 163 | - Add support for CPython 3.13 by regenerating the bundled certificates (#90) 164 | - Fix an issue where secure POST requests would fail with a connection reset 165 | by peer (#90) 166 | - Include a LICENCE 167 | - 2.0.0 168 | - Drop support for Python 2.6, 2.7, 3.4, 3.5 and 3.6 (#68) 169 | - Add support for Python 3.7, 3.8, 3.9 and 3.10 (#68) 170 | - Avoid deprecation warnings and resource warnings (#71) 171 | - Add support for Python 3.11 and 3.12, drop dependency on six (#76) 172 | - 1.0.2 173 | - Switch from travis to github actions 174 | - This will be the last release to support Python 2.6, 2.7 or 3.6 175 | - 1.0.1 176 | - httpbin_secure: fix redirect Location to have "https://" scheme (#62) - thanks 177 | @immerrr 178 | - Include regression tests in pypi tarball (#56) - thanks @kmosiejczuk 179 | - 1.0.0 180 | - Update included self-signed cert to include IP address in SAN (See #52). Full 181 | version bump because this could be a breaking change for those depending on the 182 | certificate missing the IP address in the SAN (as it seems the requests test suite 183 | does) 184 | - Only use @pytest.fixture decorator once (thanks @hroncok) 185 | - Fix a few README typos (thanks @hemberger) 186 | - 0.3.0 187 | - Allow to run httpbin on fixed port using environment variables (thanks @hroncok) 188 | - Allow server to be thread.join()ed (thanks @graingert) 189 | - Add support for Python 3.6 (thanks @graingert) 190 | - 0.2.3: 191 | - Another attempt to fix #32 (Rare bug, only happens on Travis) 192 | - 0.2.2: 193 | - Fix bug with python3 194 | - 0.2.1: 195 | - Attempt to fix strange, impossible-to-reproduce bug with broken SSL certs that only 196 | happens on Travis (#32) [Bad release, breaks py3] 197 | - 0.2.0: 198 | - Remove threaded HTTP server. I built it for Requests, but they deleted their 199 | threaded test since it didn't really work very well. The threaded server seems to 200 | cause some strange problems with HTTP chunking, so I'll just remove it since nobody 201 | is using it (I hope) 202 | - 0.1.1: 203 | - Fix weird hang with SSL on pypy (again) 204 | - 0.1.0: 205 | - Update server to use multithreaded werkzeug server 206 | - 0.0.7: 207 | - Update the certificates (they expired) 208 | - 0.0.6: 209 | - Fix an issue where pypy was hanging when a request was made with an invalid 210 | certificate 211 | - 0.0.5: 212 | - Fix broken version parsing in 0.0.4 213 | - 0.0.4: 214 | - **Bad release: Broken version parsing** 215 | - Fix `BadStatusLine` error that occurs when sending multiple requests in a single 216 | session (PR #16). Thanks @msabramo! 217 | - Fix #9 ("Can't be installed at the same time than pytest?") (PR #14). Thanks 218 | @msabramo! 219 | - Add `httpbin_ca_bundle` pytest fixture. With this fixture there is no need to 220 | specify the bundle on every request, as it will automatically set 221 | `REQUESTS_CA_BUNDLE` if using [requests](https://docs.python-requests.org/). And you 222 | don't have to care about where it is located (PR #8). Thanks @t-8ch! 223 | - 0.0.3: Add a couple test fixtures to make testing old class-based test suites easier 224 | - 0.0.2: Fixed a couple bugs with the wsgiref server to bring behavior in line with 225 | httpbin.org, thanks @jakubroztocil for the bug reports 226 | - 0.0.1: Initial release 227 | 228 | ## License 229 | 230 | The MIT License (MIT) 231 | 232 | Copyright (c) 2014-2019 Kevin McCarthy 233 | 234 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 235 | software and associated documentation files (the "Software"), to deal in the Software 236 | without restriction, including without limitation the rights to use, copy, modify, 237 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 238 | permit persons to whom the Software is furnished to do so, subject to the following 239 | conditions: 240 | 241 | The above copyright notice and this permission notice shall be included in all copies or 242 | substantial portions of the Software. 243 | 244 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 245 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 246 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 247 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT 248 | OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 249 | OTHER DEALINGS IN THE SOFTWARE. 250 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pytest-httpbin" 7 | description = "Easily test your HTTP library against a local copy of httpbin" 8 | authors = [{name = "Kevin McCarthy", email = "me@kevinmccarthy.org"}] 9 | license = {text = "MIT"} 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Intended Audience :: Developers", 13 | "Topic :: Software Development :: Testing", 14 | "Topic :: Software Development :: Libraries", 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | ] 23 | keywords = ["pytest-httpbin testing pytest httpbin"] 24 | requires-python = ">=3.8" 25 | dependencies = ["httpbin"] 26 | dynamic = ["version"] 27 | 28 | [project.readme] 29 | file = "DESCRIPTION.rst" 30 | content-type = "text/x-rst" 31 | 32 | [project.urls] 33 | Homepage = "https://github.com/kevin1024/pytest-httpbin" 34 | 35 | [project.entry-points] 36 | pytest11 = {httpbin = "pytest_httpbin.plugin"} 37 | 38 | [project.optional-dependencies] 39 | test = [ 40 | "requests", 41 | "pytest", 42 | ] 43 | 44 | [tool.setuptools] 45 | include-package-data = true 46 | 47 | [tool.setuptools.packages.find] 48 | exclude = [ 49 | "contrib", 50 | "docs", 51 | "tests*", 52 | ] 53 | namespaces = false 54 | 55 | [tool.setuptools.dynamic] 56 | version = {attr = "pytest_httpbin.version.__version__"} 57 | 58 | [tool.pytest.ini_options] 59 | addopts = "--strict-config --strict-markers" 60 | filterwarnings = [ 61 | "error", 62 | 'ignore:ast\.(Str|NameConstant) is deprecated:DeprecationWarning:_pytest', 63 | ] 64 | xfail_strict = true 65 | 66 | [tool.tox] 67 | legacy_tox_ini = """ 68 | [tox] 69 | minversion=3.28.0 70 | requires= 71 | virtualenv>=20.13.2 72 | tox-gh-actions>=2.9.1 73 | envlist = py38, py39, py310, pypy3 74 | 75 | [testenv] 76 | package = wheel 77 | wheel_build_env = .pkg 78 | extras = test 79 | commands = pytest -v -s {posargs} 80 | install_command = python -I -m pip install --use-pep517 {opts} {packages} 81 | 82 | [testenv:release] 83 | deps = 84 | build 85 | twine 86 | commands = 87 | {envpython} -m release 88 | pyproject-build --sdist 89 | twine check {toxinidir}/dist/*.* 90 | """ 91 | -------------------------------------------------------------------------------- /pytest_httpbin/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from .version import __version__ 6 | 7 | use_class_based_httpbin = pytest.mark.usefixtures("class_based_httpbin") 8 | use_class_based_httpbin_secure = pytest.mark.usefixtures("class_based_httpbin_secure") 9 | -------------------------------------------------------------------------------- /pytest_httpbin/certs.py: -------------------------------------------------------------------------------- 1 | """ 2 | certs.py 3 | ~~~~~~~~ 4 | 5 | This module returns the preferred default CA certificate bundle. 6 | 7 | If you are packaging pytest-httpbin, e.g., for a Linux distribution or a 8 | managed environment, you can change the definition of where() to return a 9 | separately packaged CA bundle. 10 | """ 11 | 12 | import os.path 13 | 14 | 15 | def where(): 16 | """Return the preferred certificate bundle.""" 17 | # vendored bundle inside Requests 18 | return os.path.join(os.path.dirname(__file__), "certs", "client.pem") 19 | 20 | 21 | if __name__ == "__main__": 22 | print(where()) 23 | -------------------------------------------------------------------------------- /pytest_httpbin/certs/README.md: -------------------------------------------------------------------------------- 1 | generated with 'python -m trustme' 2 | -------------------------------------------------------------------------------- /pytest_httpbin/certs/client.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIB0TCCAXegAwIBAgIUScnyyX1CI+ywC6GdKol8IIwuGnkwCgYIKoZIzj0EAwIw 3 | RDEbMBkGA1UECgwSdHJ1c3RtZSB2MS4xLjArZGV2MSUwIwYDVQQLDBxUZXN0aW5n 4 | IENBICNBdXNVcWJaNG81d3pjb0tCMCAXDTAwMDEwMTAwMDAwMFoYDzMwMDAwMTAx 5 | MDAwMDAwWjBEMRswGQYDVQQKDBJ0cnVzdG1lIHYxLjEuMCtkZXYxJTAjBgNVBAsM 6 | HFRlc3RpbmcgQ0EgI0F1c1VxYlo0bzV3emNvS0IwWTATBgcqhkjOPQIBBggqhkjO 7 | PQMBBwNCAARhrRi78wmZY28t3/y8MTDDCsi7Lzir4WaQm96gf4/9kSolBTFVDUvB 8 | MkSC7Yged+2bWEzTRERZQLf88uiorUnAo0UwQzAdBgNVHQ4EFgQUHymIBJV4gCrA 9 | qv+6Q9pSJFtd7PYwEgYDVR0TAQH/BAgwBgEB/wIBCTAOBgNVHQ8BAf8EBAMCAYYw 10 | CgYIKoZIzj0EAwIDSAAwRQIgLf0sybmdbJoTIgZWrU1k11oecQbdkzh+3jFtNEFn 11 | zYUCIQCRXjIBDZXtyaywk3DgIggByCQxrrB5vjlnyYTd9vNUSw== 12 | -----END CERTIFICATE----- 13 | -------------------------------------------------------------------------------- /pytest_httpbin/certs/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PRIVATE KEY----- 2 | MHcCAQEEIPNMu1H1DN9x0VLZNzO3BFp5boEGyc80XFaR1ML18uFRoAoGCCqGSM49 3 | AwEHoUQDQgAEiNIfYxmsmjemcRRpcd4qP+x1yONFBZZli7CEKxg9j3x5j1OJPeyC 4 | BQ83kogrxJYLbRjdHUx4VOCEXjffmYhnMA== 5 | -----END EC PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /pytest_httpbin/certs/server.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICTDCCAfOgAwIBAgIUZ9rBQX/YRZFcqXCIzOSAd1D0IUcwCgYIKoZIzj0EAwIw 3 | RDEbMBkGA1UECgwSdHJ1c3RtZSB2MS4xLjArZGV2MSUwIwYDVQQLDBxUZXN0aW5n 4 | IENBICNBdXNVcWJaNG81d3pjb0tCMCAXDTAwMDEwMTAwMDAwMFoYDzMwMDAwMTAx 5 | MDAwMDAwWjBGMRswGQYDVQQKDBJ0cnVzdG1lIHYxLjEuMCtkZXYxJzAlBgNVBAsM 6 | HlRlc3RpbmcgY2VydCAjLVdQNWpjLTllQ0U0S0JxMjBZMBMGByqGSM49AgEGCCqG 7 | SM49AwEHA0IABIjSH2MZrJo3pnEUaXHeKj/sdcjjRQWWZYuwhCsYPY98eY9TiT3s 8 | ggUPN5KIK8SWC20Y3R1MeFTghF4335mIZzCjgb4wgbswHQYDVR0OBBYEFCO99Ega 9 | h7pEyFEJVwe09DZzNHDtMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUHymIBJV4 10 | gCrAqv+6Q9pSJFtd7PYwLwYDVR0RAQH/BCUwI4IJbG9jYWxob3N0hwR/AAABhxAA 11 | AAAAAAAAAAAAAAAAAAABMA4GA1UdDwEB/wQEAwIFoDAqBgNVHSUBAf8EIDAeBggr 12 | BgEFBQcDAgYIKwYBBQUHAwEGCCsGAQUFBwMDMAoGCCqGSM49BAMCA0cAMEQCIHB0 13 | imdD2aQuq4DipTvnFJjmT+w8i3D/Pz8X6bPdkJW/AiATl+m4TW4BE5v1ID3ftDhz 14 | ja8s574nAjDAqcSL7otVpQ== 15 | -----END CERTIFICATE----- 16 | -------------------------------------------------------------------------------- /pytest_httpbin/plugin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpbin import app as httpbin_app 3 | 4 | from . import certs, serve 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def httpbin(request): 9 | with serve.Server(application=httpbin_app) as server: 10 | yield server 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def httpbin_secure(request): 15 | with serve.SecureServer(application=httpbin_app) as server: 16 | yield server 17 | 18 | 19 | @pytest.fixture(scope="session", params=["http", "https"]) 20 | def httpbin_both(request, httpbin, httpbin_secure): 21 | if request.param == "http": 22 | return httpbin 23 | elif request.param == "https": 24 | return httpbin_secure 25 | 26 | 27 | @pytest.fixture(scope="class") 28 | def class_based_httpbin(request, httpbin): 29 | request.cls.httpbin = httpbin 30 | 31 | 32 | @pytest.fixture(scope="class") 33 | def class_based_httpbin_secure(request, httpbin_secure): 34 | request.cls.httpbin_secure = httpbin_secure 35 | 36 | 37 | @pytest.fixture(scope="function") 38 | def httpbin_ca_bundle(monkeypatch): 39 | monkeypatch.setenv("REQUESTS_CA_BUNDLE", certs.where()) 40 | -------------------------------------------------------------------------------- /pytest_httpbin/serve.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ssl 3 | import threading 4 | from urllib.parse import urljoin 5 | from wsgiref.handlers import SimpleHandler 6 | from wsgiref.simple_server import WSGIRequestHandler, WSGIServer, make_server 7 | 8 | CERT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "certs") 9 | 10 | 11 | class ServerHandler(SimpleHandler): 12 | server_software = "Pytest-HTTPBIN/0.1.0" 13 | http_version = "1.1" 14 | 15 | def cleanup_headers(self): 16 | SimpleHandler.cleanup_headers(self) 17 | self.headers["Connection"] = "Close" 18 | 19 | def close(self): 20 | try: 21 | self.request_handler.log_request( 22 | self.status.split(" ", 1)[0], self.bytes_sent 23 | ) 24 | finally: 25 | SimpleHandler.close(self) 26 | 27 | 28 | class Handler(WSGIRequestHandler): 29 | def handle(self): 30 | """Handle a single HTTP request""" 31 | 32 | self.raw_requestline = self.rfile.readline() 33 | if not self.parse_request(): # An error code has been sent, just exit 34 | return 35 | 36 | handler = ServerHandler( 37 | self.rfile, self.wfile, self.get_stderr(), self.get_environ() 38 | ) 39 | handler.request_handler = self # backpointer for logging 40 | handler.run(self.server.get_app()) 41 | 42 | def get_environ(self): 43 | """ 44 | wsgiref simple server adds content-type text/plain to everything, this 45 | removes it if it's not actually in the headers. 46 | """ 47 | # Note: Can't use super since this is an oldstyle class in python 2.x 48 | environ = WSGIRequestHandler.get_environ(self).copy() 49 | if self.headers.get("content-type") is None: 50 | del environ["CONTENT_TYPE"] 51 | return environ 52 | 53 | 54 | class SecureWSGIServer(WSGIServer): 55 | def get_request(self): 56 | socket, address = super().get_request() 57 | try: 58 | socket.settimeout(1.0) 59 | context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 60 | context.load_cert_chain( 61 | os.path.join(CERT_DIR, "server.pem"), 62 | os.path.join(CERT_DIR, "server.key"), 63 | ) 64 | return ( 65 | context.wrap_socket( 66 | socket, server_side=True, suppress_ragged_eofs=True 67 | ), 68 | address, 69 | ) 70 | except Exception as e: 71 | print("pytest-httpbin server hit an exception serving request: %s" % e) 72 | print("attempting to ignore so the rest of the tests can run") 73 | raise 74 | 75 | def setup_environ(self): 76 | super().setup_environ() 77 | self.base_environ["HTTPS"] = "yes" 78 | 79 | 80 | class Server: 81 | """ 82 | HTTP server running a WSGI application in its own thread. 83 | """ 84 | 85 | port_envvar = "HTTPBIN_HTTP_PORT" 86 | 87 | def __init__(self, host="127.0.0.1", port=0, application=None, **kwargs): 88 | self.app = application 89 | if self.port_envvar in os.environ: 90 | port = int(os.environ[self.port_envvar]) 91 | self._server = make_server( 92 | host, port, self.app, handler_class=Handler, **kwargs 93 | ) 94 | self.host = self._server.server_address[0] 95 | self.port = self._server.server_address[1] 96 | self.protocol = "http" 97 | 98 | self._thread = threading.Thread( 99 | name=self.__class__, 100 | target=self._server.serve_forever, 101 | ) 102 | 103 | def __del__(self): 104 | if hasattr(self, "_server"): 105 | self.stop() 106 | 107 | def start(self): 108 | self._thread.start() 109 | 110 | def __enter__(self): 111 | self.start() 112 | return self 113 | 114 | def __exit__(self, *args, **kwargs): 115 | self.stop() 116 | suppress_exc = self._server.__exit__(*args, **kwargs) 117 | self._thread.join() 118 | return suppress_exc 119 | 120 | def __add__(self, other): 121 | return self.url + other 122 | 123 | def stop(self): 124 | self._server.shutdown() 125 | 126 | @property 127 | def url(self): 128 | return f"{self.protocol}://{self.host}:{self.port}" 129 | 130 | def join(self, url, allow_fragments=True): 131 | return urljoin(self.url, url, allow_fragments=allow_fragments) 132 | 133 | 134 | class SecureServer(Server): 135 | port_envvar = "HTTPBIN_HTTPS_PORT" 136 | 137 | def __init__(self, host="127.0.0.1", port=0, application=None, **kwargs): 138 | kwargs["server_class"] = SecureWSGIServer 139 | super().__init__(host, port, application, **kwargs) 140 | self.protocol = "https" 141 | -------------------------------------------------------------------------------- /pytest_httpbin/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.1.0" 2 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import shutil 4 | import sys 5 | 6 | 7 | def main(): 8 | dist = pathlib.Path(__file__).parent / "dist" 9 | shutil.rmtree(dist, ignore_errors=True) 10 | dist.mkdir() 11 | shutil.copy(os.environ["TOX_PACKAGE"], dist) 12 | 13 | 14 | if __name__ == "__main__": 15 | sys.exit(main()) 16 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | py.test $1 -v -s 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(autouse=True, scope="function") 5 | def httpbin_ca_bundle_autoused(httpbin_ca_bundle): 6 | pass 7 | -------------------------------------------------------------------------------- /tests/test_httpbin.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import sys 3 | import unittest 4 | import urllib.request 5 | 6 | import pytest 7 | import requests.exceptions 8 | import urllib3 9 | 10 | import pytest_httpbin.certs 11 | 12 | 13 | def test_httpbin_gets_injected(httpbin): 14 | assert httpbin.url 15 | 16 | 17 | def test_httpbin_accepts_get_requests(httpbin): 18 | assert requests.get(httpbin.url + "/get").status_code == 200 19 | 20 | 21 | def test_httpbin_secure_accepts_get_requests(httpbin_secure): 22 | assert requests.get(httpbin_secure.url + "/get").status_code == 200 23 | 24 | 25 | def test_httpbin_secure_accepts_lots_of_get_requests(httpbin_secure): 26 | for i in range(10): 27 | assert requests.get(httpbin_secure.url + "/get").status_code == 200 28 | 29 | 30 | def test_httpbin_accepts_lots_of_get_requests_in_single_session(httpbin): 31 | session = requests.Session() 32 | 33 | for i in range(10): 34 | assert session.get(httpbin.url + "/get").status_code == 200 35 | 36 | 37 | def test_httpbin_both(httpbin_both): 38 | # this test will get called twice, once with an http url, once with an 39 | # https url 40 | assert requests.get(httpbin_both.url + "/get").status_code == 200 41 | 42 | 43 | def test_httpbin_join(httpbin): 44 | assert httpbin.join("foo") == httpbin.url + "/foo" 45 | 46 | 47 | def test_httpbin_str(httpbin): 48 | assert httpbin + "/foo" == httpbin.url + "/foo" 49 | 50 | 51 | def test_chunked_encoding(httpbin): 52 | assert requests.get(httpbin.url + "/stream/20").status_code == 200 53 | 54 | 55 | @pytest.mark.xfail( 56 | condition=sys.version_info < (3, 8) and ssl.OPENSSL_VERSION_INFO >= (3, 0, 0), 57 | reason="fails on python3.7 openssl 3+", 58 | raises=requests.exceptions.SSLError, 59 | ) 60 | def test_chunked_encoding_secure(httpbin_secure): 61 | assert requests.get(httpbin_secure.url + "/stream/20").status_code == 200 62 | 63 | 64 | @pytest_httpbin.use_class_based_httpbin 65 | @pytest_httpbin.use_class_based_httpbin_secure 66 | class TestClassBassedTests(unittest.TestCase): 67 | def test_http(self): 68 | assert requests.get(self.httpbin.url + "/get").status_code == 200 69 | 70 | def test_http_secure(self): 71 | assert requests.get(self.httpbin_secure.url + "/get").status_code == 200 72 | 73 | 74 | def test_with_urllib2(httpbin_secure): 75 | url = httpbin_secure.url + "/get" 76 | context = ssl.create_default_context(cafile=pytest_httpbin.certs.where()) 77 | with urllib.request.urlopen(url, context=context) as response: 78 | assert response.getcode() == 200 79 | 80 | 81 | def test_with_urllib3(httpbin_secure): 82 | with urllib3.PoolManager( 83 | cert_reqs="CERT_REQUIRED", 84 | ca_certs=pytest_httpbin.certs.where(), 85 | ) as pool: 86 | pool.request( 87 | "POST", httpbin_secure.url + "/post", {"key1": "value1", "key2": "value2"} 88 | ) 89 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import re 4 | import socket 5 | 6 | import pytest 7 | import requests.exceptions 8 | from httpbin import app as httpbin_app 9 | from util import get_raw_http_response 10 | 11 | from pytest_httpbin import serve 12 | 13 | 14 | def test_content_type_header_not_automatically_added(httpbin): 15 | """ 16 | The server was automatically adding this for some reason, see issue #5 17 | """ 18 | resp = requests.get(httpbin + "/headers").json()["headers"] 19 | assert "Content-Type" not in resp 20 | 21 | 22 | def test_unicode_data(httpbin): 23 | """ 24 | UTF-8 was not getting recognized for what it was and being encoded as if it 25 | was binary, see issue #7 26 | """ 27 | resp = requests.post( 28 | httpbin + "/post", 29 | data="оживлённым".encode(), 30 | headers={ 31 | "content-type": "text/html; charset=utf-8", 32 | }, 33 | ) 34 | assert resp.json()["data"] == "оживлённым" 35 | 36 | 37 | def test_server_should_be_http_1_1(httpbin): 38 | """ 39 | The server should speak HTTP/1.1 since we live in the future, see issue #6 40 | """ 41 | resp = get_raw_http_response(httpbin.host, httpbin.port, "/get") 42 | assert resp.startswith(b"HTTP/1.1") 43 | 44 | 45 | def test_dont_crash_on_certificate_problems(httpbin_secure): 46 | with pytest.raises(requests.exceptions.SSLError): 47 | # this request used to hang 48 | requests.get(httpbin_secure + "/get", verify=True, cert=__file__) 49 | 50 | # and this request would never happen 51 | requests.get( 52 | httpbin_secure + "/get", 53 | verify=True, 54 | ) 55 | 56 | 57 | def test_dont_crash_on_handshake_timeout(httpbin_secure, capsys): 58 | with socket.socket() as sock: 59 | sock.connect((httpbin_secure.host, httpbin_secure.port)) 60 | # this request used to hang 61 | assert sock.recv(1) == b"" 62 | 63 | assert ( 64 | re.match( 65 | r"pytest-httpbin server hit an exception serving request:.* The " 66 | "handshake operation timed out\nattempting to ignore so the rest " 67 | "of the tests can run\n", 68 | capsys.readouterr().out, 69 | ) 70 | is not None 71 | ) 72 | 73 | # and this request would never happen 74 | requests.get( 75 | httpbin_secure + "/get", 76 | verify=True, 77 | ) 78 | 79 | 80 | @pytest.mark.parametrize("protocol", ("http", "https")) 81 | def test_fixed_port_environment_variables(protocol): 82 | """ 83 | Note that we cannot test the fixture here because it is session scoped 84 | and was already started. Thus, let's just test a new Server instance. 85 | """ 86 | if protocol == "http": 87 | server_cls = serve.Server 88 | envvar = "HTTPBIN_HTTP_PORT" 89 | elif protocol == "https": 90 | server_cls = serve.SecureServer 91 | envvar = "HTTPBIN_HTTPS_PORT" 92 | else: 93 | raise RuntimeError(f"Unexpected protocol param: {protocol}") 94 | 95 | # just have different port to avoid adrress already in use 96 | # if the second test run too fast after the first one (happens on pypy) 97 | port = 12345 + len(protocol) 98 | server = contextlib.nullcontext() 99 | 100 | try: 101 | envvar_original = os.environ.get(envvar, None) 102 | os.environ[envvar] = str(port) 103 | server = server_cls(application=httpbin_app) 104 | assert server.port == port 105 | finally: 106 | # if we don't do this, it blocks: 107 | with server: 108 | pass 109 | 110 | # restore the original environ: 111 | if envvar_original is None: 112 | del os.environ[envvar] 113 | else: 114 | os.environ[envvar] = envvar_original 115 | 116 | 117 | def test_redirect_location_is_https_for_secure_server(httpbin_secure): 118 | assert httpbin_secure.url.startswith("https://") 119 | response = requests.get( 120 | httpbin_secure + "/redirect-to?url=/html", allow_redirects=False 121 | ) 122 | assert response.status_code == 302 123 | assert response.headers.get("Location") 124 | assert response.headers["Location"] == "/html" 125 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | 4 | def get_raw_http_response(host, port, path): 5 | CRLF = b"\r\n" 6 | 7 | request = [ 8 | b"GET " + path.encode("ascii") + b" HTTP/1.1", 9 | b"Host: " + host.encode("ascii"), 10 | b"Connection: Close", 11 | b"", 12 | b"", 13 | ] 14 | 15 | # Connect to the server 16 | with socket.socket() as s: 17 | s.connect((host, port)) 18 | 19 | # Send an HTTP request 20 | s.send(CRLF.join(request)) 21 | 22 | # Get the response (in several parts, if necessary) 23 | response = b"" 24 | buffer = s.recv(4096) 25 | while buffer: 26 | response += buffer 27 | buffer = s.recv(4096) 28 | 29 | return response 30 | --------------------------------------------------------------------------------