├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README ├── README.rst ├── docs ├── .gitignore ├── _static │ └── .gitignore ├── _templates │ └── .gitignore ├── conf.py ├── http_client.rst ├── httplib2.rst ├── index.rst ├── interceptor.rst ├── requests.rst ├── urllib.rst └── urllib3.rst ├── setup.cfg ├── setup.py ├── tox.ini └── wsgi_intercept ├── __init__.py ├── _urllib3.py ├── http_client_intercept.py ├── httplib2_intercept.py ├── interceptor.py ├── requests_intercept.py ├── tests ├── README ├── __init__.py ├── install.py ├── test_http_client.py ├── test_httplib2.py ├── test_interceptor.py ├── test_module_interceptor.py ├── test_requests.py ├── test_response_headers.py ├── test_urllib.py ├── test_urllib3.py ├── test_wsgi_compliance.py ├── test_wsgiref.py └── wsgi_app.py ├── urllib3_intercept.py └── urllib_intercept.py /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_dispatch 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | include: 12 | - python: 3.9 13 | env: pep8 14 | - python: 3.9 15 | env: docs 16 | - python: 3.8 17 | env: py38 18 | - python: 3.9 19 | env: py39 20 | - python: "3.10" 21 | env: py310 22 | - python: "3.11" 23 | env: py311 24 | - python: "3.12" 25 | env: py312 26 | - python: "3.13" 27 | env: py313 28 | - python: pypy-3.8 29 | env: pypy3 30 | name: ${{ matrix.env }} on Python ${{ matrix.python }} 31 | steps: 32 | - uses: actions/checkout@v2 33 | - uses: actions/setup-python@v2 34 | with: 35 | python-version: ${{ matrix.python }} 36 | - run: pip install tox 37 | - run: tox 38 | env: 39 | TOXENV: ${{ matrix.env }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | 23 | #Translations 24 | *.mo 25 | 26 | #Mr Developer 27 | .mr.developer.cfg 28 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 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 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-lts-latest 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | # python: 21 | # install: 22 | # - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2012-2015 wsgi-intercept contributors. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE Makefile tox.ini 2 | recursive-include docs * 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test clean reclean tagv pypi release docs gh 2 | 3 | default: 4 | @echo "Pick a target (e.g., clean, test)" 5 | 6 | clean: 7 | find wsgi_intercept test -name "*.py[co]" |xargs rm || true 8 | find wsgi_intercept test -type d -name "__pycache__" |xargs rmdir || true 9 | rm -r dist || true 10 | rm -r build || true 11 | rm -r wsgi_intercept.egg-info || true 12 | rm *.bundle || true 13 | rm -r *-bundle* || true 14 | 15 | reclean: 16 | find wsgi_intercept test -name "*.py[co]" |xargs rm || true 17 | find wsgi_intercept test -type d -name "__pycache__" |xargs rmdir || true 18 | rm -r dist || true 19 | rm -r build || true 20 | rm -r wsgi_intercept.egg-info || true 21 | rm *.bundle || true 22 | rm -r *-bundle* || true 23 | 24 | test: 25 | tox --skip-missing-interpreters 26 | 27 | tagv: 28 | git tag -a \ 29 | -m v`python setup.py --version` \ 30 | v`python setup.py --version` 31 | git push origin master --tags 32 | 33 | pypi: 34 | python3 setup.py sdist bdist_wheel 35 | twine upload -s dist/* 36 | 37 | docs: 38 | tox -edocs 39 | 40 | release: clean test tagv reclean pypi gh 41 | 42 | gh: 43 | gh release create v`python setup.py --version` --generate-notes dist/* 44 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Installs a WSGI application in place of a real host for testing. 2 | 3 | Introduction 4 | ============ 5 | 6 | Testing a WSGI application sometimes involves starting a server at a 7 | local host and port, then pointing your test code to that address. 8 | Instead, this library lets you intercept calls to any specific host/port 9 | combination and redirect them into a `WSGI application`_ importable by 10 | your test program. Thus, you can avoid spawning multiple processes or 11 | threads to test your Web app. 12 | 13 | Supported Libaries 14 | ================== 15 | 16 | ``wsgi_intercept`` works with a variety of HTTP clients in Python 2.7, 17 | 3.8 and beyond, and in pypy. 18 | 19 | * urllib2 20 | * urllib.request 21 | * httplib 22 | * http.client 23 | * httplib2 24 | * requests 25 | * urllib3 (<2.0.0, urllib3 2 support is in progress) 26 | 27 | How Does It Work? 28 | ================= 29 | 30 | ``wsgi_intercept`` works by replacing ``httplib.HTTPConnection`` with a 31 | subclass, ``wsgi_intercept.WSGI_HTTPConnection``. This class then 32 | redirects specific server/port combinations into a WSGI application by 33 | emulating a socket. If no intercept is registered for the host and port 34 | requested, those requests are passed on to the standard handler. 35 | 36 | The easiest way to use an intercept is to import an appropriate subclass 37 | of ``~wsgi_intercept.interceptor.Interceptor`` and use that as a 38 | context manager over web requests that use the library associated with 39 | the subclass. For example:: 40 | 41 | import httplib2 42 | from wsgi_intercept.interceptor import Httplib2Interceptor 43 | from mywsgiapp import app 44 | 45 | def load_app(): 46 | return app 47 | 48 | http = httplib2.Http() 49 | with Httplib2Interceptor(load_app, host='example.com', port=80) as url: 50 | response, content = http.request('%s%s' % (url, '/path')) 51 | assert response.status == 200 52 | 53 | The interceptor class may aslo be used directly to install intercepts. 54 | See the module documentation for more information. 55 | 56 | Older versions required that the functions ``add_wsgi_intercept(host, 57 | port, app_create_fn, script_name='')`` and ``remove_wsgi_intercept(host,port)`` 58 | be used to specify which URLs should be redirected into what applications. 59 | These methods are still available, but the ``Interceptor`` classes are likely 60 | easier to use for most use cases. 61 | 62 | .. note:: ``app_create_fn`` is a *function object* returning a WSGI 63 | application; ``script_name`` becomes ``SCRIPT_NAME`` in the WSGI 64 | app's environment, if set. 65 | 66 | .. note:: If ``http_proxy`` or ``https_proxy`` is set in the environment 67 | this can cause difficulties with some of the intercepted libraries. 68 | If requests or urllib is being used, these will raise an exception 69 | if one of those variables is set. 70 | 71 | .. note:: If ``wsgi_intercept.STRICT_RESPONSE_HEADERS`` is set to ``True`` 72 | then response headers sent by an application will be checked to 73 | make sure they are of the type ``str`` native to the version of 74 | Python, as required by pep 3333. The default is ``False`` (to 75 | preserve backwards compatibility) 76 | 77 | 78 | Install 79 | ======= 80 | 81 | :: 82 | 83 | pip install -U wsgi_intercept 84 | 85 | Packages Intercepted 86 | ==================== 87 | 88 | Unfortunately each of the HTTP client libraries use their own specific 89 | mechanism for making HTTP call-outs, so individual implementations are 90 | needed. At this time there are implementations for ``httplib2``, 91 | ``urllib3`` (<2.0.0) and ``requests`` in both Python 2 and 3, ``urllib2`` and 92 | ``httplib`` in Python 2 and ``urllib.request`` and ``http.client`` 93 | in Python 3. 94 | 95 | If you are using Python 2 and need support for a different HTTP 96 | client, require a version of ``wsgi_intercept<0.6``. Earlier versions 97 | include support for ``webtest``, ``webunit`` and ``zope.testbrowser``. 98 | 99 | The best way to figure out how to use interception is to inspect 100 | `the tests`_. More comprehensive documentation available upon 101 | request. 102 | 103 | .. _the tests: https://github.com/cdent/wsgi-intercept/tree/master/test 104 | 105 | 106 | History 107 | ======= 108 | 109 | Pursuant to Ian Bicking's `"best Web testing framework"`_ post, Titus 110 | Brown put together an `in-process HTTP-to-WSGI interception mechanism`_ 111 | for his own Web testing system, twill. Because the mechanism is pretty 112 | generic -- it works at the httplib level -- Titus decided to try adding 113 | it into all of the *other* Python Web testing frameworks. 114 | 115 | The Python 2 version of wsgi-intercept was the result. Kumar McMillan 116 | later took over maintenance. 117 | 118 | The current version is tested with Python 2.7, 3.5-3.11, and pypy and pypy3. 119 | It was assembled by `Chris Dent`_. Testing and documentation improvements 120 | from `Sasha Hart`_. 121 | 122 | .. _"best Web testing framework": 123 | http://blog.ianbicking.org/best-of-the-web-app-test-frameworks.html 124 | .. _in-process HTTP-to-WSGI interception mechanism: 125 | http://www.advogato.org/person/titus/diary.html?start=119 126 | .. _WSGI application: http://www.python.org/peps/pep-3333.html 127 | .. _Chris Dent: https://github.com/cdent 128 | .. _Sasha Hart: https://github.com/sashahart 129 | 130 | Project Home 131 | ============ 132 | 133 | This project lives on `GitHub`_. Please submit all bugs, patches, 134 | failing tests, et cetera using the Issue Tracker. 135 | 136 | Additional documentation is available on `Read The Docs`_. 137 | 138 | .. _GitHub: http://github.com/cdent/wsgi-intercept 139 | .. _Read The Docs: http://wsgi-intercept.readthedocs.org/en/latest/ 140 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | -------------------------------------------------------------------------------- /docs/_static/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdent/wsgi-intercept/afd82dc88fc726c41e8aa6d9f77bb36be3ea62e5/docs/_static/.gitignore -------------------------------------------------------------------------------- /docs/_templates/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdent/wsgi-intercept/afd82dc88fc726c41e8aa6d9f77bb36be3ea62e5/docs/_templates/.gitignore -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # wsgi_intercept documentation build configuration file, created by 5 | # sphinx-quickstart on Fri May 16 13:03:52 2014. 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 | import sys 17 | import os 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | #sys.path.insert(0, os.path.abspath('.')) 23 | docroot = os.path.abspath('..') 24 | sys.path.insert(0, docroot) 25 | 26 | 27 | # -- General configuration ------------------------------------------------ 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | #needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx.ext.autodoc', 37 | 'sphinx.ext.doctest', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = 'wsgi_intercept' 54 | copyright = '2015, Titus Brown, Kumar McMillan, Chris Dent, Sasha Hart' 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | #version = '0.6.2' 62 | # The full version, including alpha/beta/rc tags. 63 | #release = '0.6.2' 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | #language = None 68 | 69 | # There are two options for replacing |today|: either, you set today to some 70 | # non-false value, then it is used: 71 | #today = '' 72 | # Else, today_fmt is used as the format for a strftime call. 73 | #today_fmt = '%B %d, %Y' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | exclude_patterns = ['_build'] 78 | 79 | # The reST default role (used for this markup: `text`) to use for all 80 | # documents. 81 | #default_role = None 82 | 83 | # If true, '()' will be appended to :func: etc. cross-reference text. 84 | #add_function_parentheses = True 85 | 86 | # If true, the current module name will be prepended to all description 87 | # unit titles (such as .. function::). 88 | #add_module_names = True 89 | 90 | # If true, sectionauthor and moduleauthor directives will be shown in the 91 | # output. They are ignored by default. 92 | #show_authors = False 93 | 94 | # The name of the Pygments (syntax highlighting) style to use. 95 | pygments_style = 'sphinx' 96 | 97 | # A list of ignored prefixes for module index sorting. 98 | #modindex_common_prefix = [] 99 | 100 | # If true, keep warnings as "system message" paragraphs in the built documents. 101 | #keep_warnings = False 102 | 103 | 104 | # -- Options for HTML output ---------------------------------------------- 105 | 106 | # The theme to use for HTML and HTML Help pages. See the documentation for 107 | # a list of builtin themes. 108 | html_theme = 'nature' 109 | 110 | # Theme options are theme-specific and customize the look and feel of a theme 111 | # further. For a list of options available for each theme, see the 112 | # documentation. 113 | #html_theme_options = {} 114 | 115 | # Add any paths that contain custom themes here, relative to this directory. 116 | #html_theme_path = [] 117 | 118 | # The name for this set of Sphinx documents. If None, it defaults to 119 | # " v documentation". 120 | #html_title = None 121 | 122 | # A shorter title for the navigation bar. Default is the same as html_title. 123 | #html_short_title = None 124 | 125 | # The name of an image file (relative to this directory) to place at the top 126 | # of the sidebar. 127 | #html_logo = None 128 | 129 | # The name of an image file (within the static path) to use as favicon of the 130 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 131 | # pixels large. 132 | #html_favicon = None 133 | 134 | # Add any paths that contain custom static files (such as style sheets) here, 135 | # relative to this directory. They are copied after the builtin static files, 136 | # so a file named "default.css" will overwrite the builtin "default.css". 137 | html_static_path = ['_static'] 138 | 139 | # Add any extra paths that contain custom files (such as robots.txt or 140 | # .htaccess) here, relative to this directory. These files are copied 141 | # directly to the root of the documentation. 142 | #html_extra_path = [] 143 | 144 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 145 | # using the given strftime format. 146 | #html_last_updated_fmt = '%b %d, %Y' 147 | 148 | # If true, SmartyPants will be used to convert quotes and dashes to 149 | # typographically correct entities. 150 | #html_use_smartypants = True 151 | 152 | # Custom sidebar templates, maps document names to template names. 153 | #html_sidebars = {} 154 | 155 | # Additional templates that should be rendered to pages, maps page names to 156 | # template names. 157 | #html_additional_pages = {} 158 | 159 | # If false, no module index is generated. 160 | #html_domain_indices = True 161 | 162 | # If false, no index is generated. 163 | #html_use_index = True 164 | 165 | # If true, the index is split into individual pages for each letter. 166 | #html_split_index = False 167 | 168 | # If true, links to the reST sources are added to the pages. 169 | #html_show_sourcelink = True 170 | 171 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 172 | #html_show_sphinx = True 173 | 174 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 175 | #html_show_copyright = True 176 | 177 | # If true, an OpenSearch description file will be output, and all pages will 178 | # contain a tag referring to it. The value of this option must be the 179 | # base URL from which the finished HTML is served. 180 | #html_use_opensearch = '' 181 | 182 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 183 | #html_file_suffix = None 184 | 185 | # Output file base name for HTML help builder. 186 | htmlhelp_basename = 'wsgi_interceptdoc' 187 | 188 | 189 | # -- Options for LaTeX output --------------------------------------------- 190 | 191 | latex_elements = { 192 | # The paper size ('letterpaper' or 'a4paper'). 193 | #'papersize': 'letterpaper', 194 | 195 | # The font size ('10pt', '11pt' or '12pt'). 196 | #'pointsize': '10pt', 197 | 198 | # Additional stuff for the LaTeX preamble. 199 | #'preamble': '', 200 | } 201 | 202 | # Grouping the document tree into LaTeX files. List of tuples 203 | # (source start file, target name, title, 204 | # author, documentclass [howto, manual, or own class]). 205 | latex_documents = [ 206 | ('index', 'wsgi_intercept.tex', 'wsgi\\_intercept Documentation', 207 | 'Titus Brown, Kumar McMillan, Chris Dent', 'manual'), 208 | ] 209 | 210 | # The name of an image file (relative to this directory) to place at the top of 211 | # the title page. 212 | #latex_logo = None 213 | 214 | # For "manual" documents, if this is true, then toplevel headings are parts, 215 | # not chapters. 216 | #latex_use_parts = False 217 | 218 | # If true, show page references after internal links. 219 | #latex_show_pagerefs = False 220 | 221 | # If true, show URL addresses after external links. 222 | #latex_show_urls = False 223 | 224 | # Documents to append as an appendix to all manuals. 225 | #latex_appendices = [] 226 | 227 | # If false, no module index is generated. 228 | #latex_domain_indices = True 229 | 230 | 231 | # -- Options for manual page output --------------------------------------- 232 | 233 | # One entry per manual page. List of tuples 234 | # (source start file, name, description, authors, manual section). 235 | man_pages = [ 236 | ('index', 'wsgi_intercept', 'wsgi_intercept Documentation', 237 | ['Titus Brown, Kumar McMillan, Chris Dent'], 1) 238 | ] 239 | 240 | # If true, show URL addresses after external links. 241 | #man_show_urls = False 242 | 243 | 244 | # -- Options for Texinfo output ------------------------------------------- 245 | 246 | # Grouping the document tree into Texinfo files. List of tuples 247 | # (source start file, target name, title, author, 248 | # dir menu entry, description, category) 249 | texinfo_documents = [ 250 | ('index', 'wsgi_intercept', 'wsgi_intercept Documentation', 251 | 'Titus Brown, Kumar McMillan, Chris Dent', 'wsgi_intercept', 'One line description of project.', 252 | 'Miscellaneous'), 253 | ] 254 | 255 | # Documents to append as an appendix to all manuals. 256 | #texinfo_appendices = [] 257 | 258 | # If false, no module index is generated. 259 | #texinfo_domain_indices = True 260 | 261 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 262 | #texinfo_show_urls = 'footnote' 263 | 264 | # If true, do not generate a @detailmenu in the "Top" node's menu. 265 | #texinfo_no_detailmenu = False 266 | 267 | 268 | # -- Options for Epub output ---------------------------------------------- 269 | 270 | # Bibliographic Dublin Core info. 271 | epub_title = 'wsgi_intercept' 272 | epub_author = 'Titus Brown, Kumar McMillan, Chris Dent' 273 | epub_publisher = 'Titus Brown, Kumar McMillan, Chris Dent' 274 | epub_copyright = '2015, Titus Brown, Kumar McMillan, Chris Dent' 275 | 276 | # The basename for the epub file. It defaults to the project name. 277 | #epub_basename = 'wsgi_intercept' 278 | 279 | # The HTML theme for the epub output. Since the default themes are not optimized 280 | # for small screen space, using the same theme for HTML and epub output is 281 | # usually not wise. This defaults to 'epub', a theme designed to save visual 282 | # space. 283 | #epub_theme = 'epub' 284 | 285 | # The language of the text. It defaults to the language option 286 | # or en if the language is not set. 287 | #epub_language = '' 288 | 289 | # The scheme of the identifier. Typical schemes are ISBN or URL. 290 | #epub_scheme = '' 291 | 292 | # The unique identifier of the text. This can be a ISBN number 293 | # or the project homepage. 294 | #epub_identifier = '' 295 | 296 | # A unique identification for the text. 297 | #epub_uid = '' 298 | 299 | # A tuple containing the cover image and cover page html template filenames. 300 | #epub_cover = () 301 | 302 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 303 | #epub_guide = () 304 | 305 | # HTML files that should be inserted before the pages created by sphinx. 306 | # The format is a list of tuples containing the path and title. 307 | #epub_pre_files = [] 308 | 309 | # HTML files shat should be inserted after the pages created by sphinx. 310 | # The format is a list of tuples containing the path and title. 311 | #epub_post_files = [] 312 | 313 | # A list of files that should not be packed into the epub file. 314 | epub_exclude_files = ['search.html'] 315 | 316 | # The depth of the table of contents in toc.ncx. 317 | #epub_tocdepth = 3 318 | 319 | # Allow duplicate toc entries. 320 | #epub_tocdup = True 321 | 322 | # Choose between 'default' and 'includehidden'. 323 | #epub_tocscope = 'default' 324 | 325 | # Fix unsupported image types using the PIL. 326 | #epub_fix_images = False 327 | 328 | # Scale large images. 329 | #epub_max_image_width = 0 330 | 331 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 332 | #epub_show_urls = 'inline' 333 | 334 | # If false, no index is generated. 335 | #epub_use_index = True 336 | -------------------------------------------------------------------------------- /docs/http_client.rst: -------------------------------------------------------------------------------- 1 | http_client_intercept 2 | ===================== 3 | 4 | .. automodule:: wsgi_intercept.http_client_intercept 5 | 6 | .. warning:: 7 | 8 | This intercept will fail to install if you access access HTTPConnection or 9 | HTTPSConnection before the intercept is installed. For example, do not use 10 | "from http.client import HTTPConnection". Instead, "import http.client" and 11 | reference http.client.HTTPConnection after the intercept is installed. 12 | 13 | Example: 14 | 15 | .. testcode:: 16 | 17 | try: 18 | import http.client as http_lib 19 | except ImportError: 20 | import httplib as http_lib 21 | from wsgi_intercept import ( 22 | http_client_intercept, add_wsgi_intercept, remove_wsgi_intercept 23 | ) 24 | 25 | 26 | def app(environ, start_response): 27 | start_response('200 OK', [('Content-Type', 'text/plain')]) 28 | return [b'Whee'] 29 | 30 | 31 | def make_app(): 32 | return app 33 | 34 | 35 | host, port = 'localhost', 80 36 | http_client_intercept.install() 37 | add_wsgi_intercept(host, port, make_app) 38 | HTTPConnection = http_lib.HTTPConnection 39 | client = HTTPConnection(host) 40 | client.request('GET', '/') 41 | response = client.getresponse() 42 | content = response.read() 43 | assert content == b'Whee' 44 | remove_wsgi_intercept(host, port) 45 | http_client_intercept.uninstall() 46 | -------------------------------------------------------------------------------- /docs/httplib2.rst: -------------------------------------------------------------------------------- 1 | httplib2_intercept 2 | ================== 3 | 4 | .. automodule:: wsgi_intercept.httplib2_intercept 5 | 6 | .. note:: No effort is made to pass SSL certificate or version 7 | information to the the underlying ``HTTPSConnection``. The 8 | assumption is that wsgi-intercept is testing the behavior 9 | of the application, not the connection. 10 | 11 | Example: 12 | 13 | .. testcode:: 14 | 15 | import httplib2 16 | from wsgi_intercept import httplib2_intercept, add_wsgi_intercept 17 | 18 | 19 | def app(environ, start_response): 20 | start_response('200 OK', [('Content-Type', 'text/plain')]) 21 | return [b'Whee'] 22 | 23 | 24 | def make_app(): 25 | return app 26 | 27 | 28 | host, port = 'localhost', 80 29 | url = 'http://{0}:{1}/'.format(host, port) 30 | httplib2_intercept.install() 31 | add_wsgi_intercept(host, port, make_app) 32 | http = httplib2.Http() 33 | resp, content = http.request(url) 34 | assert content == b'Whee' 35 | httplib2_intercept.uninstall() 36 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Documentation for wsgi_intercept 3 | ================================ 4 | 5 | .. automodule:: wsgi_intercept 6 | 7 | 8 | Examples 9 | ======== 10 | 11 | .. toctree:: 12 | :maxdepth: 1 13 | 14 | interceptor 15 | http_client 16 | httplib2 17 | requests 18 | urllib3 19 | urllib 20 | 21 | 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | 30 | -------------------------------------------------------------------------------- /docs/interceptor.rst: -------------------------------------------------------------------------------- 1 | 2 | Interceptor 3 | =========== 4 | 5 | .. automodule:: wsgi_intercept.interceptor 6 | :members: 7 | 8 | Example using `httplib2`, others are much the same: 9 | 10 | .. testcode:: 11 | 12 | import httplib2 13 | from wsgi_intercept.interceptor import Httplib2Interceptor 14 | 15 | 16 | def app(environ, start_response): 17 | start_response('200 OK', [('Content-Type', 'text/plain')]) 18 | return [b'Whee'] 19 | 20 | 21 | def make_app(): 22 | return app 23 | 24 | 25 | http = httplib2.Http() 26 | with Httplib2Interceptor(make_app, host='localhost', port=80) as url: 27 | resp, content = http.request(url) 28 | assert content == b'Whee' 29 | -------------------------------------------------------------------------------- /docs/requests.rst: -------------------------------------------------------------------------------- 1 | requests_intercept 2 | ================== 3 | 4 | .. automodule:: wsgi_intercept.requests_intercept 5 | 6 | 7 | Example: 8 | 9 | .. testcode:: 10 | 11 | import requests 12 | from wsgi_intercept import requests_intercept, add_wsgi_intercept 13 | 14 | 15 | def app(environ, start_response): 16 | start_response('200 OK', [('Content-Type', 'text/plain')]) 17 | return [b'Whee'] 18 | 19 | 20 | def make_app(): 21 | return app 22 | 23 | 24 | host, port = 'localhost', 80 25 | url = 'http://{0}:{1}/'.format(host, port) 26 | requests_intercept.install() 27 | add_wsgi_intercept(host, port, make_app) 28 | resp = requests.get(url) 29 | assert resp.content == b'Whee' 30 | requests_intercept.uninstall() 31 | -------------------------------------------------------------------------------- /docs/urllib.rst: -------------------------------------------------------------------------------- 1 | urllib_intercept 2 | ================ 3 | 4 | .. automodule:: wsgi_intercept.urllib_intercept 5 | 6 | 7 | Example: 8 | 9 | .. testcode:: 10 | 11 | try: 12 | from urllib.request import urlopen 13 | except ImportError: 14 | from urllib2 import urlopen 15 | from wsgi_intercept import ( 16 | urllib_intercept, add_wsgi_intercept, remove_wsgi_intercept 17 | ) 18 | 19 | 20 | def app(environ, start_response): 21 | start_response('200 OK', [('Content-Type', 'text/plain')]) 22 | return [b'Whee'] 23 | 24 | 25 | def make_app(): 26 | return app 27 | 28 | 29 | host, port = 'localhost', 80 30 | url = 'http://{0}:{1}/'.format(host, port) 31 | urllib_intercept.install_opener() 32 | add_wsgi_intercept(host, port, make_app) 33 | stream = urlopen(url) 34 | content = stream.read() 35 | assert content == b'Whee' 36 | remove_wsgi_intercept() 37 | -------------------------------------------------------------------------------- /docs/urllib3.rst: -------------------------------------------------------------------------------- 1 | urllib3_intercept 2 | ================== 3 | 4 | .. automodule:: wsgi_intercept.urllib3_intercept 5 | 6 | 7 | Example: 8 | 9 | .. testcode:: 10 | 11 | import urllib3 12 | from wsgi_intercept import urllib3_intercept, add_wsgi_intercept 13 | 14 | pool = urllib3.PoolManager() 15 | 16 | 17 | def app(environ, start_response): 18 | start_response('200 OK', [('Content-Type', 'text/plain')]) 19 | return [b'Whee'] 20 | 21 | 22 | def make_app(): 23 | return app 24 | 25 | 26 | host, port = 'localhost', 80 27 | url = 'http://{0}:{1}/'.format(host, port) 28 | urllib3_intercept.install() 29 | add_wsgi_intercept(host, port, make_app) 30 | resp = pool.request('GET', url) 31 | assert resp.data == b'Whee' 32 | urllib3_intercept.uninstall() 33 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pep8] 2 | ignore = E128,E127,E126 3 | 4 | [flake8] 5 | ignore = E128,E127,E126 6 | 7 | [bdist_wheel] 8 | universal = 1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | from setuptools import setup, find_packages 3 | 4 | VERSION = '1.13.1' 5 | 6 | CLASSIFIERS = """ 7 | Environment :: Web Environment 8 | Intended Audience :: Developers 9 | License :: OSI Approved :: MIT License 10 | Operating System :: OS Independent 11 | Programming Language :: Python :: 2 12 | Programming Language :: Python :: 2.7 13 | Programming Language :: Python :: 3 14 | Programming Language :: Python :: 3.8 15 | Programming Language :: Python :: 3.9 16 | Programming Language :: Python :: 3.10 17 | Programming Language :: Python :: 3.11 18 | Programming Language :: Python :: 3.12 19 | Programming Language :: Python :: 3.13 20 | Topic :: Internet :: WWW/HTTP :: WSGI 21 | Topic :: Software Development :: Testing 22 | """.strip().splitlines() 23 | 24 | 25 | META = { 26 | 'name': 'wsgi_intercept', 27 | 'version': VERSION, 28 | 'author': 'Titus Brown, Kumar McMillan, Chris Dent, Sasha Hart', 29 | 'author_email': 'cdent@peermore.com', 30 | 'description': 31 | 'wsgi_intercept installs a WSGI application in place of a ' 32 | 'real URI for testing.', 33 | # What will the name be? 34 | 'url': 'http://pypi.python.org/pypi/wsgi_intercept', 35 | 'long_description': open('README').read(), 36 | 'license': 'MIT License', 37 | 'classifiers': CLASSIFIERS, 38 | 'packages': find_packages(), 39 | 'install_requires': [ 40 | 'six', 41 | ], 42 | 'extras_require': { 43 | 'testing': [ 44 | 'pytest>=2.4', 45 | 'httplib2', 46 | 'requests>=2.0.1', 47 | 'urllib3>=1.11.0,<2.0.0', 48 | ], 49 | 'docs': [ 50 | 'sphinx', 51 | ], 52 | }, 53 | } 54 | 55 | if __name__ == '__main__': 56 | setup(**META) 57 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 1.6 3 | skipsdist = True 4 | envlist = py27,py35,py36,py38,py39,py310,py311,py312,py313,pypy,pep8,docs,readme 5 | 6 | [testenv] 7 | deps = .[testing] 8 | commands = py.test --tb=short wsgi_intercept/tests 9 | passenv = WSGI_INTERCEPT_* 10 | 11 | [testenv:pep8] 12 | deps = flake8 13 | commands = 14 | flake8 wsgi_intercept 15 | 16 | [testenv:docs] 17 | deps = .[docs] 18 | httplib2 19 | sphinx 20 | commands = 21 | rm -rf build/sphinx 22 | sphinx-build docs build/sphinx 23 | allowlist_externals = 24 | rm 25 | 26 | [testenv:readme] 27 | deps = . 28 | allowlist_externals = bash 29 | commands = bash -c "python -c 'import sys, wsgi_intercept; sys.stdout.write(wsgi_intercept.__doc__)' > README.rst" 30 | 31 | [flake8] 32 | exclude=.venv,.git,.tox,dist,*egg,*.egg-info,build,examples,docs 33 | show-source = True 34 | -------------------------------------------------------------------------------- /wsgi_intercept/__init__.py: -------------------------------------------------------------------------------- 1 | """Installs a WSGI application in place of a real host for testing. 2 | 3 | Introduction 4 | ============ 5 | 6 | Testing a WSGI application sometimes involves starting a server at a 7 | local host and port, then pointing your test code to that address. 8 | Instead, this library lets you intercept calls to any specific host/port 9 | combination and redirect them into a `WSGI application`_ importable by 10 | your test program. Thus, you can avoid spawning multiple processes or 11 | threads to test your Web app. 12 | 13 | Supported Libaries 14 | ================== 15 | 16 | ``wsgi_intercept`` works with a variety of HTTP clients in Python 2.7, 17 | 3.8 and beyond, and in pypy. 18 | 19 | * urllib2 20 | * urllib.request 21 | * httplib 22 | * http.client 23 | * httplib2 24 | * requests 25 | * urllib3 (<2.0.0, urllib3 2 support is in progress) 26 | 27 | How Does It Work? 28 | ================= 29 | 30 | ``wsgi_intercept`` works by replacing ``httplib.HTTPConnection`` with a 31 | subclass, ``wsgi_intercept.WSGI_HTTPConnection``. This class then 32 | redirects specific server/port combinations into a WSGI application by 33 | emulating a socket. If no intercept is registered for the host and port 34 | requested, those requests are passed on to the standard handler. 35 | 36 | The easiest way to use an intercept is to import an appropriate subclass 37 | of ``~wsgi_intercept.interceptor.Interceptor`` and use that as a 38 | context manager over web requests that use the library associated with 39 | the subclass. For example:: 40 | 41 | import httplib2 42 | from wsgi_intercept.interceptor import Httplib2Interceptor 43 | from mywsgiapp import app 44 | 45 | def load_app(): 46 | return app 47 | 48 | http = httplib2.Http() 49 | with Httplib2Interceptor(load_app, host='example.com', port=80) as url: 50 | response, content = http.request('%s%s' % (url, '/path')) 51 | assert response.status == 200 52 | 53 | The interceptor class may aslo be used directly to install intercepts. 54 | See the module documentation for more information. 55 | 56 | Older versions required that the functions ``add_wsgi_intercept(host, 57 | port, app_create_fn, script_name='')`` and ``remove_wsgi_intercept(host,port)`` 58 | be used to specify which URLs should be redirected into what applications. 59 | These methods are still available, but the ``Interceptor`` classes are likely 60 | easier to use for most use cases. 61 | 62 | .. note:: ``app_create_fn`` is a *function object* returning a WSGI 63 | application; ``script_name`` becomes ``SCRIPT_NAME`` in the WSGI 64 | app's environment, if set. 65 | 66 | .. note:: If ``http_proxy`` or ``https_proxy`` is set in the environment 67 | this can cause difficulties with some of the intercepted libraries. 68 | If requests or urllib is being used, these will raise an exception 69 | if one of those variables is set. 70 | 71 | .. note:: If ``wsgi_intercept.STRICT_RESPONSE_HEADERS`` is set to ``True`` 72 | then response headers sent by an application will be checked to 73 | make sure they are of the type ``str`` native to the version of 74 | Python, as required by pep 3333. The default is ``False`` (to 75 | preserve backwards compatibility) 76 | 77 | 78 | Install 79 | ======= 80 | 81 | :: 82 | 83 | pip install -U wsgi_intercept 84 | 85 | Packages Intercepted 86 | ==================== 87 | 88 | Unfortunately each of the HTTP client libraries use their own specific 89 | mechanism for making HTTP call-outs, so individual implementations are 90 | needed. At this time there are implementations for ``httplib2``, 91 | ``urllib3`` (<2.0.0) and ``requests`` in both Python 2 and 3, ``urllib2`` and 92 | ``httplib`` in Python 2 and ``urllib.request`` and ``http.client`` 93 | in Python 3. 94 | 95 | If you are using Python 2 and need support for a different HTTP 96 | client, require a version of ``wsgi_intercept<0.6``. Earlier versions 97 | include support for ``webtest``, ``webunit`` and ``zope.testbrowser``. 98 | 99 | The best way to figure out how to use interception is to inspect 100 | `the tests`_. More comprehensive documentation available upon 101 | request. 102 | 103 | .. _the tests: https://github.com/cdent/wsgi-intercept/tree/master/test 104 | 105 | 106 | History 107 | ======= 108 | 109 | Pursuant to Ian Bicking's `"best Web testing framework"`_ post, Titus 110 | Brown put together an `in-process HTTP-to-WSGI interception mechanism`_ 111 | for his own Web testing system, twill. Because the mechanism is pretty 112 | generic -- it works at the httplib level -- Titus decided to try adding 113 | it into all of the *other* Python Web testing frameworks. 114 | 115 | The Python 2 version of wsgi-intercept was the result. Kumar McMillan 116 | later took over maintenance. 117 | 118 | The current version is tested with Python 2.7, 3.5-3.11, and pypy and pypy3. 119 | It was assembled by `Chris Dent`_. Testing and documentation improvements 120 | from `Sasha Hart`_. 121 | 122 | .. _"best Web testing framework": 123 | http://blog.ianbicking.org/best-of-the-web-app-test-frameworks.html 124 | .. _in-process HTTP-to-WSGI interception mechanism: 125 | http://www.advogato.org/person/titus/diary.html?start=119 126 | .. _WSGI application: http://www.python.org/peps/pep-3333.html 127 | .. _Chris Dent: https://github.com/cdent 128 | .. _Sasha Hart: https://github.com/sashahart 129 | 130 | Project Home 131 | ============ 132 | 133 | This project lives on `GitHub`_. Please submit all bugs, patches, 134 | failing tests, et cetera using the Issue Tracker. 135 | 136 | Additional documentation is available on `Read The Docs`_. 137 | 138 | .. _GitHub: http://github.com/cdent/wsgi-intercept 139 | .. _Read The Docs: http://wsgi-intercept.readthedocs.org/en/latest/ 140 | """ 141 | from __future__ import print_function 142 | 143 | import sys 144 | import traceback 145 | from io import BytesIO 146 | 147 | # Don't use six here because it is unquote_to_bytes that we want in 148 | # Python 3. 149 | try: 150 | from urllib.parse import unquote_to_bytes as url_unquote 151 | except ImportError: 152 | from urllib import unquote as url_unquote 153 | 154 | import six 155 | from six.moves.http_client import HTTPConnection, HTTPSConnection 156 | 157 | 158 | # Set this to True to cause response headers from the intercepted 159 | # app to be confirmed as bytestrings, behaving as some wsgi servers. 160 | STRICT_RESPONSE_HEADERS = False 161 | 162 | 163 | debuglevel = 0 164 | # 1 basic 165 | # 2 verbose 166 | 167 | #### 168 | 169 | # 170 | # Specify which hosts/ports to target for interception to a given WSGI app. 171 | # 172 | # For simplicity's sake, intercept ENTIRE host/port combinations; 173 | # intercepting only specific URL subtrees gets complicated, because we don't 174 | # have that information in the HTTPConnection.connect() function that does the 175 | # redirection. 176 | # 177 | # format: key=(host, port), value=(create_app, top_url) 178 | # 179 | # (top_url becomes the SCRIPT_NAME) 180 | 181 | _wsgi_intercept = {} 182 | 183 | 184 | def add_wsgi_intercept(host, port, app_create_fn, script_name=''): 185 | """ 186 | Add a WSGI intercept call for host:port, using the app returned 187 | by app_create_fn with a SCRIPT_NAME of 'script_name' (default ''). 188 | """ 189 | _wsgi_intercept[(host, port)] = (app_create_fn, script_name) 190 | 191 | 192 | def remove_wsgi_intercept(*args): 193 | """ 194 | Remove the WSGI intercept call for (host, port). If no arguments are 195 | given, removes all intercepts 196 | """ 197 | global _wsgi_intercept 198 | if len(args) == 0: 199 | _wsgi_intercept = {} 200 | else: 201 | key = (args[0], args[1]) 202 | if key in _wsgi_intercept: 203 | del _wsgi_intercept[key] 204 | return len(_wsgi_intercept) 205 | 206 | 207 | # 208 | # make_environ: behave like a Web server. Take in 'input', and behave 209 | # as if you're bound to 'host' and 'port'; build an environment dict 210 | # for the WSGI app. 211 | # 212 | # This is where the magic happens, folks. 213 | # 214 | def make_environ(inp, host, port, script_name): 215 | """ 216 | Take 'inp' as if it were HTTP-speak being received on host:port, 217 | and parse it into a WSGI-ok environment dictionary. Return the 218 | dictionary. 219 | 220 | Set 'SCRIPT_NAME' from the 'script_name' input, and, if present, 221 | remove it from the beginning of the PATH_INFO variable. 222 | """ 223 | # 224 | # parse the input up to the first blank line (or its end). 225 | # 226 | 227 | environ = {} 228 | 229 | method_line = inp.readline() 230 | if six.PY3: 231 | method_line = method_line.decode('ISO-8859-1') 232 | 233 | content_type = None 234 | content_length = None 235 | cookies = [] 236 | 237 | for line in inp: 238 | if not line.strip(): 239 | break 240 | 241 | k, v = line.strip().split(b':', 1) 242 | v = v.lstrip() 243 | # Make header value a "native" string. PEP 3333 requires that 244 | # string-like things in headers be of type `str`. Much of the 245 | # time this isn't a problem but the SimpleCookie library does 246 | # type checking against `type("")`. 247 | v = str(v.decode('ISO-8859-1')) 248 | 249 | # 250 | # take care of special headers, and for the rest, put them 251 | # into the environ with HTTP_ in front. 252 | # 253 | 254 | if k.lower() == b'content-type': 255 | content_type = v 256 | elif k.lower() == b'content-length': 257 | content_length = v 258 | elif k.lower() == b'cookie' or k.lower() == b'cookie2': 259 | cookies.append(v) 260 | else: 261 | h = k.upper() 262 | h = h.replace(b'-', b'_') 263 | environ['HTTP_' + str(h.decode('ISO-8859-1'))] = v 264 | 265 | if debuglevel >= 2: 266 | print('HEADER:', k, v) 267 | 268 | # 269 | # decode the method line 270 | # 271 | 272 | if debuglevel >= 2: 273 | print('METHOD LINE:', method_line) 274 | 275 | method, url, protocol = method_line.split(' ') 276 | 277 | # Store the URI as requested by the user, without modification 278 | # so that PATH_INFO munging can be corrected. 279 | environ['REQUEST_URI'] = url 280 | environ['RAW_URI'] = url 281 | 282 | # clean the script_name off of the url, if it's there. 283 | if not url.startswith(script_name): 284 | script_name = '' # @CTB what to do -- bad URL. scrap? 285 | else: 286 | url = url[len(script_name):] 287 | 288 | url = url.split('?', 1) 289 | path_info = url_unquote(url[0]) 290 | query_string = "" 291 | if len(url) == 2: 292 | query_string = url[1] 293 | 294 | if debuglevel: 295 | print("method: %s; script_name: %s; path_info: %s; query_string: %s" % 296 | (method, script_name, path_info, query_string)) 297 | 298 | r = inp.read() 299 | inp = BytesIO(r) 300 | 301 | # 302 | # fill out our dictionary. 303 | # 304 | 305 | # In Python3 turn the bytes of the path info into a string of 306 | # latin-1 code points, because that's what the spec says we must 307 | # do to be like a server. Later various libraries will be forced 308 | # to decode and then reencode to get the UTF-8 that everyone 309 | # wants. 310 | if six.PY3: 311 | path_info = path_info.decode('latin-1') 312 | 313 | environ.update({ 314 | "wsgi.version": (1, 0), 315 | "wsgi.url_scheme": "http", 316 | "wsgi.input": inp, # to read for POSTs 317 | "wsgi.errors": sys.stderr, 318 | "wsgi.multithread": 0, 319 | "wsgi.multiprocess": 0, 320 | "wsgi.run_once": 0, 321 | 322 | "PATH_INFO": path_info, 323 | "REMOTE_ADDR": '127.0.0.1', 324 | "REQUEST_METHOD": method, 325 | "SCRIPT_NAME": script_name, 326 | "SERVER_NAME": host, 327 | "SERVER_PORT": port, 328 | "SERVER_PROTOCOL": protocol, 329 | }) 330 | 331 | # 332 | # query_string, content_type & length are optional. 333 | # 334 | 335 | if query_string: 336 | environ['QUERY_STRING'] = query_string 337 | 338 | if content_type: 339 | environ['CONTENT_TYPE'] = content_type 340 | if debuglevel >= 2: 341 | print('CONTENT-TYPE:', content_type) 342 | if content_length: 343 | environ['CONTENT_LENGTH'] = content_length 344 | if debuglevel >= 2: 345 | print('CONTENT-LENGTH:', content_length) 346 | 347 | # 348 | # handle cookies. 349 | # 350 | if cookies: 351 | environ['HTTP_COOKIE'] = "; ".join(cookies) 352 | 353 | if debuglevel: 354 | print('WSGI environ dictionary:', environ) 355 | 356 | return environ 357 | 358 | 359 | class WSGIAppError(Exception): 360 | """ 361 | An exception that wraps any Exception raised by the WSGI app 362 | that is called. This is done for two reasons: it ensures that 363 | intercepted libraries (such as requests) which use exceptions 364 | to trigger behaviors are not interfered with by exceptions from 365 | the WSGI app. It also helps to define a solid boundary, akin 366 | to the network boundary between server and client, in the 367 | testing environment. 368 | """ 369 | def __init__(self, error, exc_info): 370 | Exception.__init__(self) 371 | self.error = error 372 | self.exception_type = exc_info[0] 373 | self.exception_value = exc_info[1] 374 | self.traceback = exc_info[2] 375 | 376 | def __str__(self): 377 | frame = traceback.extract_tb(self.traceback)[-1] 378 | formatted = "{0!r} at {1}:{2}".format( 379 | self.error, 380 | frame[0], 381 | frame[1], 382 | ) 383 | return formatted 384 | 385 | 386 | # 387 | # fake socket for WSGI intercept stuff. 388 | # 389 | class wsgi_fake_socket: 390 | """ 391 | Handle HTTP traffic and stuff into a WSGI application object instead. 392 | 393 | Note that this class assumes: 394 | 395 | 1. 'makefile' is called (by the response class) only after all of the 396 | data has been sent to the socket by the request class; 397 | 2. non-persistent (i.e. non-HTTP/1.1) connections. 398 | """ 399 | def __init__(self, app, host, port, script_name, https=False): 400 | self.app = app # WSGI app object 401 | self.host = host 402 | self.port = port 403 | self.script_name = script_name # SCRIPT_NAME (app mount point) 404 | 405 | self.inp = BytesIO() # stuff written into this "socket" 406 | self.write_results = [] # results from the 'write_fn' 407 | self.results = None # results from running the app 408 | self.output = BytesIO() # all output from the app, incl headers 409 | self.https = https 410 | 411 | def makefile(self, *args, **kwargs): 412 | """ 413 | 'makefile' is called by the HTTPResponse class once all of the 414 | data has been written. So, in this interceptor class, we need to: 415 | 416 | 1. build a start_response function that grabs all the headers 417 | returned by the WSGI app; 418 | 2. create a wsgi.input file object 'inp', containing all of the 419 | traffic; 420 | 3. build an environment dict out of the traffic in inp; 421 | 4. run the WSGI app & grab the result object; 422 | 5. concatenate & return the result(s) read from the result object. 423 | """ 424 | 425 | # dynamically construct the start_response function for no good reason. 426 | 427 | self.headers = [] 428 | 429 | def start_response(status, headers, exc_info=None): 430 | # construct the HTTP request. 431 | self.output.write( 432 | b"HTTP/1.0 " + status.encode('ISO-8859-1') + b"\n") 433 | # Keep the reference of the headers list to write them only 434 | # when the whole application have been processed 435 | self.headers = headers 436 | return self.write_results.append 437 | 438 | # construct the wsgi.input file from everything that's been 439 | # written to this "socket". 440 | inp = BytesIO(self.inp.getvalue()) 441 | 442 | # build the environ dictionary. 443 | environ = make_environ(inp, self.host, self.port, self.script_name) 444 | if self.https: 445 | environ['wsgi.url_scheme'] = 'https' 446 | 447 | # run the application. 448 | try: 449 | app_result = self.app(environ, start_response) 450 | except Exception as error: 451 | raise WSGIAppError(error, sys.exc_info()) 452 | self.result = iter(app_result) 453 | 454 | ### 455 | 456 | # read all of the results. the trick here is to get the *first* 457 | # bit of data from the app via the generator, *then* grab & return 458 | # the data passed back from the 'write' function, and then return 459 | # the generator data. this is because the 'write' fn doesn't 460 | # necessarily get called until the first result is requested from 461 | # the app function. 462 | 463 | try: 464 | generator_data = None 465 | try: 466 | generator_data = next(self.result) 467 | 468 | finally: 469 | # send the headers 470 | 471 | for k, v in self.headers: 472 | if STRICT_RESPONSE_HEADERS: 473 | if not (isinstance(k, str) and isinstance(v, str)): 474 | raise TypeError( 475 | "Header has a key '%s' or value '%s' " 476 | "which is not a native str." % (k, v)) 477 | try: 478 | k = k.encode('ISO-8859-1') 479 | except AttributeError: 480 | pass 481 | try: 482 | v = v.encode('ISO-8859-1') 483 | except AttributeError: 484 | pass 485 | self.output.write(k + b': ' + v + b"\n") 486 | self.output.write(b'\n') 487 | 488 | for data in self.write_results: 489 | self.output.write(data) 490 | 491 | if generator_data is not None: 492 | try: 493 | self.output.write(generator_data) 494 | except TypeError as exc: 495 | raise TypeError('bytes required in response: %s' % exc) 496 | 497 | while 1: 498 | data = next(self.result) 499 | self.output.write(data) 500 | 501 | except StopIteration: 502 | pass 503 | 504 | if hasattr(app_result, 'close'): 505 | app_result.close() 506 | 507 | if debuglevel >= 2: 508 | print("***", self.output.getvalue(), "***") 509 | 510 | # return the concatenated results. 511 | return BytesIO(self.output.getvalue()) 512 | 513 | def sendall(self, content): 514 | """ 515 | Save all the traffic to self.inp. 516 | """ 517 | if debuglevel >= 2: 518 | print(">>>", content, ">>>") 519 | 520 | try: 521 | self.inp.write(content) 522 | except TypeError: 523 | self.inp.write(content.encode('utf-8')) 524 | 525 | def close(self): 526 | "Do nothing, for now." 527 | pass 528 | 529 | 530 | # 531 | # WSGI_HTTPConnection 532 | # 533 | class WSGI_HTTPConnection(HTTPConnection): 534 | """ 535 | Intercept all traffic to certain hosts & redirect into a WSGI 536 | application object. 537 | """ 538 | def get_app(self, host, port): 539 | """ 540 | Return the app object for the given (host, port). 541 | """ 542 | key = (host, int(port)) 543 | 544 | app, script_name = None, None 545 | 546 | if key in _wsgi_intercept: 547 | (app_fn, script_name) = _wsgi_intercept[key] 548 | app = app_fn() 549 | 550 | return app, script_name 551 | 552 | def connect(self): 553 | """ 554 | Override the connect() function to intercept calls to certain 555 | host/ports. 556 | 557 | If no app at host/port has been registered for interception then 558 | a normal HTTPConnection is made. 559 | """ 560 | if debuglevel: 561 | sys.stderr.write('connect: %s, %s\n' % (self.host, self.port,)) 562 | 563 | try: 564 | (app, script_name) = self.get_app(self.host, self.port) 565 | if app: 566 | if debuglevel: 567 | sys.stderr.write('INTERCEPTING call to %s:%s\n' % 568 | (self.host, self.port,)) 569 | self.sock = wsgi_fake_socket(app, self.host, self.port, 570 | script_name) 571 | else: 572 | HTTPConnection.connect(self) 573 | 574 | except Exception: 575 | if debuglevel: # intercept & print out tracebacks 576 | traceback.print_exc() 577 | raise 578 | 579 | 580 | # 581 | # WSGI_HTTPSConnection 582 | # 583 | 584 | 585 | class WSGI_HTTPSConnection(HTTPSConnection, WSGI_HTTPConnection): 586 | """ 587 | Intercept all traffic to certain hosts & redirect into a WSGI 588 | application object. 589 | """ 590 | def get_app(self, host, port): 591 | """ 592 | Return the app object for the given (host, port). 593 | """ 594 | key = (host, int(port)) 595 | 596 | app, script_name = None, None 597 | 598 | if key in _wsgi_intercept: 599 | (app_fn, script_name) = _wsgi_intercept[key] 600 | app = app_fn() 601 | 602 | return app, script_name 603 | 604 | def connect(self): 605 | """ 606 | Override the connect() function to intercept calls to certain 607 | host/ports. 608 | 609 | If no app at host/port has been registered for interception then 610 | a normal HTTPSConnection is made. 611 | """ 612 | if debuglevel: 613 | sys.stderr.write('connect: %s, %s\n' % (self.host, self.port,)) 614 | 615 | try: 616 | (app, script_name) = self.get_app(self.host, self.port) 617 | if app: 618 | if debuglevel: 619 | sys.stderr.write('INTERCEPTING call to %s:%s\n' % 620 | (self.host, self.port,)) 621 | self.sock = wsgi_fake_socket(app, self.host, self.port, 622 | script_name, https=True) 623 | else: 624 | try: 625 | import ssl 626 | if hasattr(self, '_context'): 627 | self._context.check_hostname = self.assert_hostname 628 | self._check_hostname = self.assert_hostname # Py3.6 629 | if hasattr(ssl, 'VerifyMode'): 630 | # Support for Python3.6 and higher 631 | if isinstance(self.cert_reqs, ssl.VerifyMode): 632 | self._context.verify_mode = self.cert_reqs 633 | else: 634 | self._context.verify_mode = ssl.VerifyMode[ 635 | self.cert_reqs] 636 | elif isinstance(self.cert_reqs, six.string_types): 637 | # Support for Python3.5 and below 638 | self._context.verify_mode = getattr(ssl, 639 | self.cert_reqs, 640 | self._context.verify_mode) 641 | else: 642 | self._context.verify_mode = self.cert_reqs 643 | 644 | if not hasattr(self, 'key_file'): 645 | self.key_file = None 646 | if not hasattr(self, 'cert_file'): 647 | self.cert_file = None 648 | if not hasattr(self, '_context'): 649 | try: 650 | self._context = ssl.create_default_context() 651 | except AttributeError: 652 | self._context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) 653 | self._context.options |= ssl.OP_NO_SSLv2 654 | if not hasattr(self, 'check_hostname'): 655 | self._check_hostname = ( 656 | self._context.verify_mode != ssl.CERT_NONE 657 | ) 658 | else: 659 | self._check_hostname = self.check_hostname 660 | except (ImportError, AttributeError): 661 | import traceback 662 | traceback.print_exc() 663 | HTTPSConnection.connect(self) 664 | 665 | except Exception: 666 | if debuglevel: # intercept & print out tracebacks 667 | traceback.print_exc() 668 | raise 669 | -------------------------------------------------------------------------------- /wsgi_intercept/_urllib3.py: -------------------------------------------------------------------------------- 1 | """Common code of urllib3 (<2.0.0) and requests intercepts.""" 2 | 3 | import os 4 | import sys 5 | 6 | from . import WSGI_HTTPConnection, WSGI_HTTPSConnection, wsgi_fake_socket 7 | 8 | 9 | wsgi_fake_socket.settimeout = lambda self, timeout: None 10 | 11 | 12 | def make_urllib3_override(HTTPConnectionPool, HTTPSConnectionPool, 13 | HTTPConnection, HTTPSConnection): 14 | 15 | class HTTP_WSGIInterceptor(WSGI_HTTPConnection, HTTPConnection): 16 | def __init__(self, *args, **kwargs): 17 | if 'strict' in kwargs and sys.version_info > (3, 0): 18 | kwargs.pop('strict') 19 | kwargs.pop('socket_options', None) 20 | kwargs.pop('server_hostname', None) 21 | WSGI_HTTPConnection.__init__(self, *args, **kwargs) 22 | HTTPConnection.__init__(self, *args, **kwargs) 23 | 24 | class HTTPS_WSGIInterceptor(WSGI_HTTPSConnection, HTTPSConnection): 25 | is_verified = True 26 | 27 | def __init__(self, *args, **kwargs): 28 | if 'strict' in kwargs and sys.version_info > (3, 0): 29 | kwargs.pop('strict') 30 | kwargs.pop('socket_options', None) 31 | kwargs.pop('key_password', None) 32 | kwargs.pop('server_hostname', None) 33 | kwargs.pop('ssl_context', None) 34 | if sys.version_info > (3, 12): 35 | kwargs.pop('key_file', None) 36 | kwargs.pop('cert_file', None) 37 | WSGI_HTTPSConnection.__init__(self, *args, **kwargs) 38 | HTTPSConnection.__init__(self, *args, **kwargs) 39 | 40 | def install(): 41 | if 'http_proxy' in os.environ or 'https_proxy' in os.environ: 42 | raise RuntimeError( 43 | 'http_proxy or https_proxy set in environment, please unset') 44 | HTTPConnectionPool.ConnectionCls = HTTP_WSGIInterceptor 45 | HTTPSConnectionPool.ConnectionCls = HTTPS_WSGIInterceptor 46 | 47 | def uninstall(): 48 | HTTPConnectionPool.ConnectionCls = HTTPConnection 49 | HTTPSConnectionPool.ConnectionCls = HTTPSConnection 50 | 51 | return install, uninstall 52 | -------------------------------------------------------------------------------- /wsgi_intercept/http_client_intercept.py: -------------------------------------------------------------------------------- 1 | """Intercept HTTP connections that use httplib (Py2) or http.client (Py3). 2 | """ 3 | 4 | try: 5 | import http.client as http_lib 6 | except ImportError: 7 | import httplib as http_lib 8 | 9 | from . import WSGI_HTTPConnection, WSGI_HTTPSConnection 10 | 11 | try: 12 | from http.client import ( 13 | HTTPConnection as OriginalHTTPConnection, 14 | HTTPSConnection as OriginalHTTPSConnection 15 | ) 16 | except ImportError: 17 | from httplib import ( 18 | HTTPConnection as OriginalHTTPConnection, 19 | HTTPSConnection as OriginalHTTPSConnection 20 | ) 21 | 22 | 23 | class HTTP_WSGIInterceptor(WSGI_HTTPConnection, http_lib.HTTPConnection): 24 | pass 25 | 26 | 27 | class HTTPS_WSGIInterceptor(WSGI_HTTPSConnection, http_lib.HTTPSConnection, 28 | HTTP_WSGIInterceptor): 29 | 30 | def __init__(self, host, **kwargs): 31 | self.host = host 32 | try: 33 | self.port = kwargs['port'] 34 | except KeyError: 35 | self.port = None 36 | HTTP_WSGIInterceptor.__init__(self, host, **kwargs) 37 | 38 | 39 | def install(): 40 | http_lib.HTTPConnection = HTTP_WSGIInterceptor 41 | http_lib.HTTPSConnection = HTTPS_WSGIInterceptor 42 | 43 | 44 | def uninstall(): 45 | http_lib.HTTPConnection = OriginalHTTPConnection 46 | http_lib.HTTPSConnection = OriginalHTTPSConnection 47 | -------------------------------------------------------------------------------- /wsgi_intercept/httplib2_intercept.py: -------------------------------------------------------------------------------- 1 | """Intercept HTTP connections that use 2 | `httplib2 `_. 3 | """ 4 | 5 | import sys 6 | 7 | from httplib2 import (SCHEME_TO_CONNECTION, HTTPConnectionWithTimeout, 8 | HTTPSConnectionWithTimeout) 9 | 10 | from . import (HTTPConnection, HTTPSConnection, WSGI_HTTPConnection, 11 | WSGI_HTTPSConnection) 12 | 13 | HTTPInterceptorMixin = WSGI_HTTPConnection 14 | HTTPSInterceptorMixin = WSGI_HTTPSConnection 15 | 16 | 17 | class HTTP_WSGIInterceptorWithTimeout(HTTPInterceptorMixin, 18 | HTTPConnectionWithTimeout): 19 | def __init__(self, host, port=None, strict=None, timeout=None, 20 | proxy_info=None, source_address=None): 21 | 22 | # In Python3 strict is deprecated 23 | if sys.version_info[0] < 3: 24 | HTTPConnection.__init__(self, host, port=port, strict=strict, 25 | timeout=timeout, source_address=source_address) 26 | else: 27 | HTTPConnection.__init__(self, host, port=port, 28 | timeout=timeout, source_address=source_address) 29 | 30 | 31 | class HTTPS_WSGIInterceptorWithTimeout(HTTPSInterceptorMixin, 32 | HTTPSConnectionWithTimeout): 33 | def __init__(self, host, port=None, strict=None, timeout=None, 34 | proxy_info=None, ca_certs=None, source_address=None, 35 | **kwargs): 36 | 37 | # ignore proxy_info and ca_certs 38 | # In Python3 strict is deprecated 39 | if sys.version_info[0] < 3: 40 | HTTPSConnection.__init__(self, host, port=port, strict=strict, 41 | timeout=timeout, source_address=source_address) 42 | else: 43 | HTTPSConnection.__init__(self, host, port=port, 44 | timeout=timeout, source_address=source_address) 45 | 46 | 47 | def install(): 48 | SCHEME_TO_CONNECTION['http'] = HTTP_WSGIInterceptorWithTimeout 49 | SCHEME_TO_CONNECTION['https'] = HTTPS_WSGIInterceptorWithTimeout 50 | 51 | 52 | def uninstall(): 53 | SCHEME_TO_CONNECTION['http'] = HTTPConnectionWithTimeout 54 | SCHEME_TO_CONNECTION['https'] = HTTPSConnectionWithTimeout 55 | -------------------------------------------------------------------------------- /wsgi_intercept/interceptor.py: -------------------------------------------------------------------------------- 1 | """Context manager based WSGI interception. 2 | """ 3 | 4 | from importlib import import_module 5 | from uuid import uuid4 6 | 7 | from six.moves.urllib import parse as urlparse 8 | 9 | import wsgi_intercept 10 | 11 | 12 | class Interceptor(object): 13 | """A convenience class over the guts of wsgi_intercept. 14 | 15 | An Interceptor subclass provides a clean entry point to the wsgi_intercept 16 | functionality in two ways: by encapsulating the interception addition and 17 | removal in methods and by providing a context manager that automates the 18 | process of addition and removal. 19 | 20 | Each Interceptor subclass is associated with a specific http library. 21 | 22 | Each class may be passed a url or a host and a port. If no args are passed 23 | a hostname will be automatically generated and the resulting url will be 24 | returned by the context manager. 25 | """ 26 | 27 | def __init__(self, app, host=None, port=80, prefix=None, url=None): 28 | assert app 29 | if (not host and not url): 30 | host = str(uuid4()) 31 | 32 | self.app = app 33 | 34 | if url: 35 | self._init_from_url(url) 36 | self.url = url 37 | else: 38 | self.host = host 39 | self.port = int(port) 40 | self.script_name = prefix or '' 41 | self.url = self._url_from_primitives() 42 | 43 | self._module = import_module('.%s' % self.MODULE_NAME, 44 | package='wsgi_intercept') 45 | 46 | def __enter__(self): 47 | self.install_intercept() 48 | return self.url 49 | 50 | def __exit__(self, exc_type, value, traceback): 51 | self.uninstall_intercept() 52 | 53 | def _url_from_primitives(self): 54 | if self.port == 443: 55 | scheme = 'https' 56 | else: 57 | scheme = 'http' 58 | 59 | if self.port and self.port not in [443, 80]: 60 | port = ':%s' % self.port 61 | else: 62 | port = '' 63 | netloc = self.host + port 64 | 65 | return urlparse.urlunsplit((scheme, netloc, self.script_name, 66 | None, None)) 67 | 68 | def _init_from_url(self, url): 69 | port = None 70 | parsed_url = urlparse.urlsplit(url) 71 | if ':' in parsed_url.netloc: 72 | host, port = parsed_url.netloc.split(':') 73 | else: 74 | host = parsed_url.netloc 75 | if not port: 76 | if parsed_url.scheme == 'https': 77 | port = 443 78 | else: 79 | port = 80 80 | path = parsed_url.path 81 | if path == '/' or not path: 82 | self.script_name = '' 83 | else: 84 | self.script_name = path 85 | self.host = host 86 | self.port = int(port) 87 | 88 | def install_intercept(self): 89 | self._module.install() 90 | wsgi_intercept.add_wsgi_intercept(self.host, self.port, self.app, 91 | script_name=self.script_name) 92 | 93 | def uninstall_intercept(self): 94 | remaining = wsgi_intercept.remove_wsgi_intercept(self.host, self.port) 95 | # Only remove the module's class overrides if there are no intercepts 96 | # left. Otherwise nested context managers cannot work. 97 | if not remaining: 98 | self.uninstall_module() 99 | 100 | def uninstall_module(self): 101 | self._module.uninstall() 102 | 103 | 104 | class HttpClientInterceptor(Interceptor): 105 | """Interceptor for httplib and http.client.""" 106 | 107 | MODULE_NAME = 'http_client_intercept' 108 | 109 | 110 | class Httplib2Interceptor(Interceptor): 111 | """Interceptor for httplib2.""" 112 | 113 | MODULE_NAME = 'httplib2_intercept' 114 | 115 | 116 | class RequestsInterceptor(Interceptor): 117 | """Interceptor for requests.""" 118 | 119 | MODULE_NAME = 'requests_intercept' 120 | 121 | 122 | class Urllib3Interceptor(Interceptor): 123 | """Interceptor for requests.""" 124 | 125 | MODULE_NAME = 'urllib3_intercept' 126 | 127 | 128 | class UrllibInterceptor(Interceptor): 129 | """Interceptor for urllib2 and urllib.request.""" 130 | 131 | MODULE_NAME = 'urllib_intercept' 132 | -------------------------------------------------------------------------------- /wsgi_intercept/requests_intercept.py: -------------------------------------------------------------------------------- 1 | """Intercept HTTP connections that use 2 | `requests `_. 3 | """ 4 | 5 | from requests.packages.urllib3.connectionpool import (HTTPConnectionPool, 6 | HTTPSConnectionPool) 7 | from requests.packages.urllib3.connection import (HTTPConnection, 8 | HTTPSConnection) 9 | from ._urllib3 import make_urllib3_override 10 | 11 | 12 | install, uninstall = make_urllib3_override(HTTPConnectionPool, 13 | HTTPSConnectionPool, 14 | HTTPConnection, 15 | HTTPSConnection) 16 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/README: -------------------------------------------------------------------------------- 1 | 2 | If you wish to run the tests without those tests which speak to 3 | the internet (there are a few that validate that the intercept 4 | does not intercept), set the WSGI_INTERCEPT_SKIP_NETWORK environment 5 | variable to "True". Any other value or no value will mean that 6 | the network tests will run. 7 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import wsgi_intercept 3 | 4 | # Ensure that our test apps are sending strict headers. 5 | wsgi_intercept.STRICT_RESPONSE_HEADERS = True 6 | 7 | 8 | if os.environ.get('USER') == 'cdent': 9 | import warnings 10 | warnings.simplefilter('error') 11 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/install.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import wsgi_intercept 4 | 5 | 6 | skipnetwork = pytest.mark.skipif(os.environ.get( 7 | 'WSGI_INTERCEPT_SKIP_NETWORK', 'False').lower() == 'true', 8 | reason="skip network requested" 9 | ) 10 | 11 | 12 | class BaseInstalledApp(object): 13 | def __init__(self, app, host, port=80, script_name='', 14 | install=None, uninstall=None, proxy=None): 15 | self.app = app 16 | self.host = host 17 | self.port = port 18 | self.script_name = script_name 19 | self._install = install or (lambda: None) 20 | self._uninstall = uninstall or (lambda: None) 21 | self._hits = 0 22 | self._internals = {} 23 | self._proxy = proxy 24 | 25 | def __call__(self, environ, start_response): 26 | self._hits += 1 27 | self._internals = environ 28 | return self.app(environ, start_response) 29 | 30 | def success(self): 31 | return self._hits > 0 32 | 33 | def get_internals(self): 34 | return self._internals 35 | 36 | def install_wsgi_intercept(self): 37 | wsgi_intercept.add_wsgi_intercept( 38 | self.host, self.port, self.factory, script_name=self.script_name) 39 | 40 | def uninstall_wsgi_intercept(self): 41 | wsgi_intercept.remove_wsgi_intercept(self.host, self.port) 42 | 43 | def install(self): 44 | if self._proxy: 45 | os.environ['http_proxy'] = self._proxy 46 | self._install() 47 | self.install_wsgi_intercept() 48 | 49 | def uninstall(self): 50 | if self._proxy: 51 | del os.environ['http_proxy'] 52 | self.uninstall_wsgi_intercept() 53 | self._uninstall() 54 | 55 | def factory(self): 56 | return self 57 | 58 | def __enter__(self): 59 | self.install() 60 | return self 61 | 62 | def __exit__(self, *args, **kwargs): 63 | self.uninstall() 64 | 65 | 66 | def installer_class(module=None, install=None, uninstall=None): 67 | if module: 68 | install = install or getattr(module, 'install', None) 69 | uninstall = uninstall or getattr(module, 'uninstall', None) 70 | 71 | class InstalledApp(BaseInstalledApp): 72 | def __init__(self, app, host, port=80, script_name='', proxy=None): 73 | BaseInstalledApp.__init__( 74 | self, app=app, host=host, port=port, script_name=script_name, 75 | install=install, uninstall=uninstall, proxy=proxy) 76 | 77 | return InstalledApp 78 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_http_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wsgi_intercept import http_client_intercept, WSGIAppError 3 | from . import wsgi_app 4 | from .install import installer_class, skipnetwork 5 | try: 6 | import http.client as http_lib 7 | except ImportError: 8 | import httplib as http_lib 9 | 10 | HOST = 'some_hopefully_nonexistant_domain' 11 | 12 | InstalledApp = installer_class(http_client_intercept) 13 | 14 | 15 | def test_http(): 16 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80) as app: 17 | http_client = http_lib.HTTPConnection(HOST) 18 | http_client.request('GET', '/') 19 | content = http_client.getresponse().read() 20 | http_client.close() 21 | assert content == b'WSGI intercept successful!\n' 22 | assert app.success() 23 | 24 | 25 | def test_https(): 26 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 27 | http_client = http_lib.HTTPSConnection(HOST) 28 | http_client.request('GET', '/') 29 | content = http_client.getresponse().read() 30 | http_client.close() 31 | assert content == b'WSGI intercept successful!\n' 32 | assert app.success() 33 | 34 | 35 | def test_other(): 36 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=8080) as app: 37 | http_client = http_lib.HTTPConnection(HOST + ':8080') 38 | http_client.request('GET', '/') 39 | content = http_client.getresponse().read() 40 | http_client.close() 41 | assert content == b'WSGI intercept successful!\n' 42 | assert app.success() 43 | 44 | 45 | def test_proxy_handling(): 46 | """Proxy variable no impact.""" 47 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80, 48 | proxy='some.host:1234') as app: 49 | http_client = http_lib.HTTPConnection(HOST) 50 | http_client.request('GET', '/') 51 | content = http_client.getresponse().read() 52 | http_client.close() 53 | assert content == b'WSGI intercept successful!\n' 54 | assert app.success() 55 | 56 | 57 | def test_app_error(): 58 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 59 | http_client = http_lib.HTTPConnection(HOST) 60 | with pytest.raises(WSGIAppError): 61 | http_client.request('GET', '/') 62 | http_client.getresponse().read() 63 | http_client.close() 64 | 65 | 66 | @skipnetwork 67 | def test_http_not_intercepted(): 68 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 69 | http_client = http_lib.HTTPConnection('google.com') 70 | http_client.request('GET', '/') 71 | response = http_client.getresponse() 72 | http_client.close() 73 | assert 200 <= int(response.status) < 400 74 | 75 | 76 | @skipnetwork 77 | def test_https_not_intercepted(): 78 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=443): 79 | http_client = http_lib.HTTPSConnection('google.com') 80 | http_client.request('GET', '/') 81 | response = http_client.getresponse() 82 | http_client.close() 83 | assert 200 <= int(response.status) < 400 84 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_httplib2.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wsgi_intercept import httplib2_intercept, WSGIAppError 3 | from . import wsgi_app 4 | from .install import installer_class 5 | import httplib2 6 | from socket import gaierror 7 | 8 | HOST = 'some_hopefully_nonexistant_domain' 9 | 10 | InstalledApp = installer_class(httplib2_intercept) 11 | 12 | 13 | def test_http(): 14 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80) as app: 15 | http = httplib2.Http() 16 | resp, content = http.request( 17 | 'http://some_hopefully_nonexistant_domain:80/') 18 | assert content == b'WSGI intercept successful!\n' 19 | assert app.success() 20 | 21 | 22 | def test_http_default_port(): 23 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80) as app: 24 | http = httplib2.Http() 25 | resp, content = http.request( 26 | 'http://some_hopefully_nonexistant_domain/') 27 | assert content == b'WSGI intercept successful!\n' 28 | assert app.success() 29 | 30 | 31 | def test_http_other_port(): 32 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=8080) as app: 33 | http = httplib2.Http() 34 | resp, content = http.request( 35 | 'http://some_hopefully_nonexistant_domain:8080/') 36 | assert content == b'WSGI intercept successful!\n' 37 | assert app.success() 38 | 39 | environ = app.get_internals() 40 | assert environ['wsgi.url_scheme'] == 'http' 41 | 42 | 43 | def test_bogus_domain(): 44 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80): 45 | with pytest.raises(gaierror): 46 | httplib2_intercept.HTTP_WSGIInterceptorWithTimeout( 47 | "_nonexistant_domain_").connect() 48 | 49 | 50 | def test_proxy_handling(): 51 | """Proxy has no impact.""" 52 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80, 53 | proxy='some_proxy.com:1234') as app: 54 | http = httplib2.Http() 55 | resp, content = http.request( 56 | 'http://some_hopefully_nonexistant_domain:80/') 57 | assert content == b'WSGI intercept successful!\n' 58 | assert app.success() 59 | 60 | 61 | def test_https(): 62 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 63 | http = httplib2.Http() 64 | resp, content = http.request( 65 | 'https://some_hopefully_nonexistant_domain:443/') 66 | assert app.success() 67 | 68 | 69 | def test_https_default_port(): 70 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 71 | http = httplib2.Http() 72 | resp, content = http.request( 73 | 'https://some_hopefully_nonexistant_domain/') 74 | assert app.success() 75 | 76 | environ = app.get_internals() 77 | assert environ['wsgi.url_scheme'] == 'https' 78 | 79 | 80 | def test_app_error(): 81 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 82 | http = httplib2.Http() 83 | with pytest.raises(WSGIAppError): 84 | http.request( 85 | 'http://some_hopefully_nonexistant_domain/') 86 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_interceptor.py: -------------------------------------------------------------------------------- 1 | """Tests of using the context manager style. 2 | 3 | The context manager is based on the InterceptFixture used in gabbi. 4 | """ 5 | 6 | 7 | import socket 8 | from uuid import uuid4 9 | 10 | import pytest 11 | import requests 12 | import urllib3 13 | from httplib2 import Http, ServerNotFoundError 14 | # don't use six as the monkey patching gets confused 15 | try: 16 | import http.client as http_client 17 | except ImportError: 18 | import httplib as http_client 19 | from six.moves.urllib.request import urlopen 20 | from six.moves.urllib.error import URLError 21 | 22 | from wsgi_intercept.interceptor import ( 23 | Interceptor, HttpClientInterceptor, Httplib2Interceptor, 24 | RequestsInterceptor, UrllibInterceptor, Urllib3Interceptor) 25 | from .wsgi_app import simple_app 26 | 27 | httppool = urllib3.PoolManager() 28 | 29 | 30 | def app(): 31 | return simple_app 32 | 33 | 34 | # Base 35 | 36 | def test_interceptor_instance(): 37 | hostname = str(uuid4()) 38 | port = 9999 39 | interceptor = Httplib2Interceptor(app=app, host=hostname, port=port, 40 | prefix='/foobar') 41 | assert isinstance(interceptor, Interceptor) 42 | assert interceptor.app == app 43 | assert interceptor.host == hostname 44 | assert interceptor.port == port 45 | assert interceptor.script_name == '/foobar' 46 | assert interceptor.url == 'http://%s:%s/foobar' % (hostname, port) 47 | 48 | 49 | def test_intercept_by_url_no_port(): 50 | # Test for https://github.com/cdent/wsgi-intercept/issues/41 51 | hostname = str(uuid4()) 52 | url = 'http://%s/foobar' % hostname 53 | interceptor = Httplib2Interceptor(app=app, url=url) 54 | assert isinstance(interceptor, Interceptor) 55 | assert interceptor.app == app 56 | assert interceptor.host == hostname 57 | assert interceptor.port == 80 58 | assert interceptor.script_name == '/foobar' 59 | assert interceptor.url == url 60 | 61 | 62 | # http_lib 63 | 64 | def test_httpclient_interceptor_host(): 65 | hostname = str(uuid4()) 66 | port = 9999 67 | with HttpClientInterceptor(app=app, host=hostname, port=port): 68 | client = http_client.HTTPConnection(hostname, port) 69 | client.request('GET', '/') 70 | response = client.getresponse() 71 | content = response.read().decode('utf-8') 72 | assert response.status == 200 73 | assert 'WSGI intercept successful!' in content 74 | 75 | 76 | def test_httpclient_interceptor_url(): 77 | hostname = str(uuid4()) 78 | port = 9999 79 | url = 'http://%s:%s/' % (hostname, port) 80 | with HttpClientInterceptor(app=app, url=url): 81 | client = http_client.HTTPConnection(hostname, port) 82 | client.request('GET', '/') 83 | response = client.getresponse() 84 | content = response.read().decode('utf-8') 85 | assert response.status == 200 86 | assert 'WSGI intercept successful!' in content 87 | 88 | 89 | def test_httpclient_in_out(): 90 | hostname = str(uuid4()) 91 | port = 9999 92 | url = 'http://%s:%s/' % (hostname, port) 93 | with HttpClientInterceptor(app=app, url=url): 94 | client = http_client.HTTPConnection(hostname, port) 95 | client.request('GET', '/') 96 | response = client.getresponse() 97 | content = response.read().decode('utf-8') 98 | assert response.status == 200 99 | assert 'WSGI intercept successful!' in content 100 | 101 | # outside the context manager the intercept does not work 102 | with pytest.raises(socket.gaierror): 103 | client = http_client.HTTPConnection(hostname, port) 104 | client.request('GET', '/') 105 | 106 | 107 | # Httplib2 108 | 109 | def test_httplib2_interceptor_host(): 110 | hostname = str(uuid4()) 111 | port = 9999 112 | http = Http() 113 | with Httplib2Interceptor(app=app, host=hostname, port=port) as url: 114 | response, content = http.request(url) 115 | assert response.status == 200 116 | assert 'WSGI intercept successful!' in content.decode('utf-8') 117 | 118 | 119 | def test_httplib2_interceptor_https_host(): 120 | hostname = str(uuid4()) 121 | port = 443 122 | http = Http() 123 | with Httplib2Interceptor(app=app, host=hostname, port=port) as url: 124 | assert url == 'https://%s' % hostname 125 | response, content = http.request(url) 126 | assert response.status == 200 127 | assert 'WSGI intercept successful!' in content.decode('utf-8') 128 | 129 | 130 | def test_httplib2_interceptor_no_host(): 131 | # no hostname or port, one will be generated automatically 132 | # we never actually know what it is 133 | http = Http() 134 | with Httplib2Interceptor(app=app) as url: 135 | response, content = http.request(url) 136 | assert response.status == 200 137 | assert 'WSGI intercept successful!' in content.decode('utf-8') 138 | 139 | 140 | def test_httplib2_interceptor_url(): 141 | hostname = str(uuid4()) 142 | port = 9999 143 | url = 'http://%s:%s/' % (hostname, port) 144 | http = Http() 145 | with Httplib2Interceptor(app=app, url=url) as target_url: 146 | response, content = http.request(target_url) 147 | assert response.status == 200 148 | assert 'WSGI intercept successful!' in content.decode('utf-8') 149 | 150 | 151 | def test_httplib2_in_out(): 152 | hostname = str(uuid4()) 153 | port = 9999 154 | url = 'http://%s:%s/' % (hostname, port) 155 | http = Http() 156 | with Httplib2Interceptor(app=app, url=url) as target_url: 157 | response, content = http.request(target_url) 158 | assert response.status == 200 159 | assert 'WSGI intercept successful!' in content.decode('utf-8') 160 | 161 | # outside the context manager the intercept does not work 162 | with pytest.raises(ServerNotFoundError): 163 | http.request(url) 164 | 165 | 166 | # Requests 167 | 168 | def test_requests_interceptor_host(): 169 | hostname = str(uuid4()) 170 | port = 9999 171 | with RequestsInterceptor(app=app, host=hostname, port=port) as url: 172 | response = requests.get(url) 173 | assert response.status_code == 200 174 | assert 'WSGI intercept successful!' in response.text 175 | 176 | 177 | def test_requests_interceptor_url(): 178 | hostname = str(uuid4()) 179 | port = 9999 180 | url = 'http://%s:%s/' % (hostname, port) 181 | with RequestsInterceptor(app=app, url=url) as target_url: 182 | response = requests.get(target_url) 183 | assert response.status_code == 200 184 | assert 'WSGI intercept successful!' in response.text 185 | 186 | 187 | def test_requests_in_out(): 188 | hostname = str(uuid4()) 189 | port = 9999 190 | url = 'http://%s:%s/' % (hostname, port) 191 | with RequestsInterceptor(app=app, url=url) as target_url: 192 | response = requests.get(target_url) 193 | assert response.status_code == 200 194 | assert 'WSGI intercept successful!' in response.text 195 | 196 | # outside the context manager the intercept does not work 197 | with pytest.raises(requests.ConnectionError): 198 | requests.get(url) 199 | 200 | 201 | # urllib3 202 | 203 | def test_urllib3_interceptor_host(): 204 | hostname = str(uuid4()) 205 | port = 9999 206 | with Urllib3Interceptor(app=app, host=hostname, port=port) as url: 207 | response = httppool.request('GET', url) 208 | assert response.status == 200 209 | assert 'WSGI intercept successful!' in str(response.data) 210 | 211 | 212 | def test_urllib3_interceptor_url(): 213 | hostname = str(uuid4()) 214 | port = 9999 215 | url = 'http://%s:%s/' % (hostname, port) 216 | with Urllib3Interceptor(app=app, url=url) as target_url: 217 | response = httppool.request('GET', target_url) 218 | assert response.status == 200 219 | assert 'WSGI intercept successful!' in str(response.data) 220 | 221 | 222 | def test_urllib3_in_out(): 223 | hostname = str(uuid4()) 224 | port = 9999 225 | url = 'http://%s:%s/' % (hostname, port) 226 | with Urllib3Interceptor(app=app, url=url) as target_url: 227 | response = httppool.request('GET', target_url) 228 | assert response.status == 200 229 | assert 'WSGI intercept successful!' in str(response.data) 230 | 231 | # outside the context manager the intercept does not work 232 | with pytest.raises(urllib3.exceptions.ProtocolError): 233 | httppool.request('GET', url, retries=False) 234 | 235 | 236 | # urllib 237 | 238 | def test_urllib_interceptor_host(): 239 | hostname = str(uuid4()) 240 | port = 9999 241 | with UrllibInterceptor(app=app, host=hostname, port=port) as url: 242 | response = urlopen(url) 243 | assert response.code == 200 244 | assert 'WSGI intercept successful!' in response.read().decode('utf-8') 245 | 246 | 247 | def test_urllib_interceptor_url(): 248 | hostname = str(uuid4()) 249 | port = 9999 250 | url = 'http://%s:%s/' % (hostname, port) 251 | with UrllibInterceptor(app=app, url=url) as target_url: 252 | response = urlopen(target_url) 253 | assert response.code == 200 254 | assert 'WSGI intercept successful!' in response.read().decode('utf-8') 255 | 256 | 257 | def test_urllib_in_out(): 258 | hostname = str(uuid4()) 259 | port = 9999 260 | url = 'http://%s:%s/' % (hostname, port) 261 | with UrllibInterceptor(app=app, url=url) as target_url: 262 | response = urlopen(target_url) 263 | assert response.code == 200 264 | assert 'WSGI intercept successful!' in response.read().decode('utf-8') 265 | 266 | # outside the context manager the intercept does not work 267 | with pytest.raises(URLError): 268 | urlopen(url) 269 | 270 | 271 | def test_double_nested_context_interceptor(): 272 | hostname = str(uuid4()) 273 | url1 = 'http://%s:%s/' % (hostname, 9998) 274 | url2 = 'http://%s:%s/' % (hostname, 9999) 275 | 276 | with Urllib3Interceptor(app=app, url=url1): 277 | with Urllib3Interceptor(app=app, url=url2): 278 | 279 | response = httppool.request('GET', url1) 280 | assert response.status == 200 281 | assert 'WSGI intercept successful!' in str(response.data) 282 | 283 | response = httppool.request('GET', url2) 284 | assert response.status == 200 285 | assert 'WSGI intercept successful!' in str(response.data) 286 | 287 | response = httppool.request('GET', url1) 288 | assert response.status == 200 289 | assert 'WSGI intercept successful!' in str(response.data) 290 | 291 | # outside the inner context manager url2 does not work 292 | with pytest.raises(urllib3.exceptions.HTTPError): 293 | httppool.request('GET', url2, retries=False) 294 | 295 | # outside both context managers neither url works 296 | with pytest.raises(urllib3.exceptions.HTTPError): 297 | httppool.request('GET', url2, retries=False) 298 | with pytest.raises(urllib3.exceptions.HTTPError): 299 | httppool.request('GET', url1, retries=False) 300 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_module_interceptor.py: -------------------------------------------------------------------------------- 1 | """Test intercepting a full module with interceptor.""" 2 | 3 | from uuid import uuid4 4 | 5 | from httplib2 import Http 6 | 7 | from wsgi_intercept.interceptor import Httplib2Interceptor 8 | from .wsgi_app import simple_app 9 | 10 | 11 | def app(): 12 | return simple_app 13 | 14 | 15 | def setup_module(module): 16 | module.host = str(uuid4()) 17 | module.intercept = Httplib2Interceptor(app, host=module.host) 18 | module.intercept.install_intercept() 19 | 20 | 21 | def teardown_module(module): 22 | module.intercept.uninstall_intercept() 23 | 24 | 25 | def test_simple_request(): 26 | global host 27 | http = Http() 28 | response, content = http.request('http://%s/' % host) 29 | assert response.status == 200 30 | assert 'WSGI intercept successful!' in content.decode('utf-8') 31 | 32 | 33 | def test_another_request(): 34 | global host 35 | http = Http() 36 | response, content = http.request('http://%s/foobar' % host) 37 | assert response.status == 200 38 | assert 'WSGI intercept successful!' in content.decode('utf-8') 39 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_requests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from wsgi_intercept import requests_intercept, WSGIAppError 4 | from . import wsgi_app 5 | from .install import installer_class, skipnetwork 6 | import requests 7 | from requests.exceptions import ConnectionError 8 | 9 | HOST = 'some_hopefully_nonexistant_domain' 10 | 11 | InstalledApp = installer_class(requests_intercept) 12 | 13 | 14 | def test_http(): 15 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80) as app: 16 | resp = requests.get('http://some_hopefully_nonexistant_domain:80/') 17 | assert resp.content == b'WSGI intercept successful!\n' 18 | assert app.success() 19 | 20 | 21 | def test_http_default_port(): 22 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80) as app: 23 | resp = requests.get('http://some_hopefully_nonexistant_domain/') 24 | assert resp.content == b'WSGI intercept successful!\n' 25 | assert app.success() 26 | 27 | 28 | def test_http_other_port(): 29 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=8080) as app: 30 | resp = requests.get('http://some_hopefully_nonexistant_domain:8080/') 31 | assert resp.content == b'WSGI intercept successful!\n' 32 | assert app.success() 33 | environ = app.get_internals() 34 | assert environ['wsgi.url_scheme'] == 'http' 35 | 36 | 37 | def test_bogus_domain(): 38 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80): 39 | with pytest.raises(ConnectionError): 40 | requests.get("http://_nonexistant_domain_") 41 | 42 | 43 | def test_proxy_handling(): 44 | with pytest.raises(RuntimeError) as exc: 45 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80, 46 | proxy='some_proxy.com:1234'): 47 | requests.get('http://some_hopefully_nonexistant_domain:80/') 48 | assert 'http_proxy or https_proxy set in environment' in str(exc.value) 49 | # We need to do this by hand because the exception was raised 50 | # during the entry of the context manager, so the exit handler 51 | # wasn't reached. 52 | del os.environ['http_proxy'] 53 | 54 | 55 | def test_https(): 56 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 57 | resp = requests.get('https://some_hopefully_nonexistant_domain:443/') 58 | assert resp.content == b'WSGI intercept successful!\n' 59 | assert app.success() 60 | 61 | 62 | def test_https_no_ssl_verification_intercepted(): 63 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 64 | resp = requests.get('https://some_hopefully_nonexistant_domain:443/', 65 | verify=False) 66 | assert resp.content == b'WSGI intercept successful!\n' 67 | assert app.success() 68 | 69 | 70 | @skipnetwork 71 | def test_https_no_ssl_verification_not_intercepted(): 72 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 73 | resp = requests.get('https://self-signed.badssl.com/', verify=False) 74 | assert resp.status_code >= 200 and resp.status_code < 300 75 | assert not app.success() 76 | 77 | 78 | def test_https_default_port(): 79 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 80 | resp = requests.get('https://some_hopefully_nonexistant_domain/') 81 | assert resp.content == b'WSGI intercept successful!\n' 82 | assert app.success() 83 | environ = app.get_internals() 84 | assert environ['wsgi.url_scheme'] == 'https' 85 | 86 | 87 | def test_app_error(): 88 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 89 | with pytest.raises(WSGIAppError): 90 | requests.get('http://some_hopefully_nonexistant_domain/') 91 | 92 | 93 | @skipnetwork 94 | def test_http_not_intercepted(): 95 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 96 | resp = requests.get("http://google.com") 97 | assert resp.status_code >= 200 and resp.status_code < 300 98 | 99 | 100 | @skipnetwork 101 | def test_https_not_intercepted(): 102 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 103 | resp = requests.get("https://google.com") 104 | assert resp.status_code >= 200 and resp.status_code < 300 105 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_response_headers.py: -------------------------------------------------------------------------------- 1 | """Test response header validations. 2 | 3 | Response headers are supposed to be bytestrings and some servers, 4 | notably will experience an error if they are given headers with 5 | the wrong form. Since wsgi-intercept is standing in as a server, 6 | it should behave like one on this front. At the moment it does 7 | not. There are tests for how it delivers request headers, but 8 | not the other way round. Let's write some tests to fix that. 9 | """ 10 | 11 | import pytest 12 | import requests 13 | import six 14 | 15 | import wsgi_intercept 16 | from wsgi_intercept.interceptor import RequestsInterceptor 17 | 18 | 19 | class HeaderApp(object): 20 | """A simple app that returns whatever headers we give it.""" 21 | 22 | def __init__(self, headers): 23 | self.headers = headers 24 | 25 | def __call__(self, environ, start_response): 26 | 27 | headers = [] 28 | for header in self.headers: 29 | headers.append((header, self.headers[header])) 30 | start_response('200 OK', headers) 31 | return [b''] 32 | 33 | 34 | def app(headers): 35 | return HeaderApp(headers) 36 | 37 | 38 | def test_header_app(): 39 | """Make sure the header apps returns headers. 40 | 41 | Many libraries normalize headers to strings so we're not 42 | going to get exact matches. 43 | """ 44 | header_value = 'alpha' 45 | header_value_str = 'alpha' 46 | 47 | def header_app(): 48 | return app({'request-id': header_value}) 49 | 50 | with RequestsInterceptor(header_app) as url: 51 | response = requests.get(url) 52 | 53 | assert response.headers['request-id'] == header_value_str 54 | 55 | 56 | def test_encoding_violation(): 57 | """If the header is unicode we expect boom.""" 58 | header_key = 'request-id' 59 | if six.PY2: 60 | header_value = u'alpha' 61 | else: 62 | header_value = b'alpha' 63 | # we expect our http library to give us a str 64 | returned_header = 'alpha' 65 | 66 | def header_app(): 67 | return app({header_key: header_value}) 68 | 69 | # save original 70 | strict_response_headers = wsgi_intercept.STRICT_RESPONSE_HEADERS 71 | 72 | # With STRICT_RESPONSE_HEADERS True, response headers must be 73 | # native str. 74 | with RequestsInterceptor(header_app) as url: 75 | wsgi_intercept.STRICT_RESPONSE_HEADERS = True 76 | 77 | with pytest.raises(TypeError) as error: 78 | response = requests.get(url) 79 | 80 | assert ( 81 | str(error.value) == "Header has a key '%s' or value '%s' " 82 | "which is not a native str." % (header_key, header_value)) 83 | 84 | # When False, other types of strings are okay. 85 | wsgi_intercept.STRICT_RESPONSE_HEADERS = False 86 | 87 | response = requests.get(url) 88 | 89 | assert response.headers['request-id'] == returned_header 90 | 91 | # reset back to saved original 92 | wsgi_intercept.STRICT_RESPONSE_HEADERS = \ 93 | strict_response_headers 94 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_urllib.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from wsgi_intercept import urllib_intercept, WSGIAppError 4 | from . import wsgi_app 5 | from .install import installer_class, skipnetwork 6 | try: 7 | import urllib.request as url_lib 8 | except ImportError: 9 | import urllib2 as url_lib 10 | 11 | HOST = 'some_hopefully_nonexistant_domain' 12 | 13 | InstalledApp = installer_class(install=urllib_intercept.install_opener) 14 | 15 | 16 | def test_http(): 17 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80) as app: 18 | url_lib.urlopen('http://some_hopefully_nonexistant_domain:80/') 19 | assert app.success() 20 | 21 | 22 | def test_http_default_port(): 23 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80) as app: 24 | url_lib.urlopen('http://some_hopefully_nonexistant_domain/') 25 | assert app.success() 26 | 27 | 28 | def test_http_other_port(): 29 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=8080) as app: 30 | url_lib.urlopen('http://some_hopefully_nonexistant_domain:8080/') 31 | assert app.success() 32 | environ = app.get_internals() 33 | assert environ['wsgi.url_scheme'] == 'http' 34 | 35 | 36 | def test_proxy_handling(): 37 | """Like requests, urllib gets confused about proxy early on.""" 38 | with pytest.raises(RuntimeError) as exc: 39 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80, 40 | proxy='some.host:1234'): 41 | url_lib.urlopen('http://some_hopefully_nonexistant_domain:80/') 42 | assert 'http_proxy or https_proxy set in environment' in str(exc.value) 43 | # We need to do this by hand because the exception was raised 44 | # during the entry of the context manager, so the exit handler 45 | # wasn't reached. 46 | del os.environ['http_proxy'] 47 | 48 | 49 | def test_https(): 50 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 51 | url_lib.urlopen('https://some_hopefully_nonexistant_domain:443/') 52 | assert app.success() 53 | 54 | 55 | def test_https_default_port(): 56 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 57 | url_lib.urlopen('https://some_hopefully_nonexistant_domain/') 58 | assert app.success() 59 | environ = app.get_internals() 60 | assert environ['wsgi.url_scheme'] == 'https' 61 | 62 | 63 | def test_app_error(): 64 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 65 | with pytest.raises(WSGIAppError): 66 | url_lib.urlopen('http://some_hopefully_nonexistant_domain/') 67 | 68 | 69 | @skipnetwork 70 | def test_http_not_intercepted(): 71 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80): 72 | response = url_lib.urlopen('http://google.com/') 73 | assert 200 <= int(response.code) < 400 74 | 75 | 76 | @skipnetwork 77 | def test_https_not_intercepted(): 78 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443): 79 | response = url_lib.urlopen('https://google.com/') 80 | assert 200 <= int(response.code) < 400 81 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_urllib3.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from wsgi_intercept import urllib3_intercept, WSGIAppError 4 | from . import wsgi_app 5 | from .install import installer_class, skipnetwork 6 | import urllib3 7 | 8 | HOST = 'some_hopefully_nonexistant_domain' 9 | 10 | InstalledApp = installer_class(urllib3_intercept) 11 | http = urllib3.PoolManager() 12 | 13 | 14 | def test_http(): 15 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80) as app: 16 | resp = http.request( 17 | 'GET', 'http://some_hopefully_nonexistant_domain:80/') 18 | assert resp.data == b'WSGI intercept successful!\n' 19 | assert app.success() 20 | 21 | 22 | def test_http_default_port(): 23 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80) as app: 24 | resp = http.request('GET', 'http://some_hopefully_nonexistant_domain/') 25 | assert resp.data == b'WSGI intercept successful!\n' 26 | assert app.success() 27 | 28 | 29 | def test_http_other_port(): 30 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=8080) as app: 31 | resp = http.request( 32 | 'GET', 'http://some_hopefully_nonexistant_domain:8080/') 33 | assert resp.data == b'WSGI intercept successful!\n' 34 | assert app.success() 35 | environ = app.get_internals() 36 | assert environ['wsgi.url_scheme'] == 'http' 37 | 38 | 39 | def test_bogus_domain(): 40 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80): 41 | with pytest.raises(urllib3.exceptions.ProtocolError): 42 | http.request("GET", "http://_nonexistant_domain_", retries=False) 43 | 44 | 45 | def test_proxy_handling(): 46 | with pytest.raises(RuntimeError) as exc: 47 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80, 48 | proxy='some_proxy.com:1234'): 49 | http.request('GET', 'http://some_hopefully_nonexistant_domain:80/') 50 | assert 'http_proxy or https_proxy set in environment' in str(exc.value) 51 | # We need to do this by hand because the exception was raised 52 | # during the entry of the context manager, so the exit handler 53 | # wasn't reached. 54 | del os.environ['http_proxy'] 55 | 56 | 57 | def test_https(): 58 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 59 | resp = http.request( 60 | 'GET', 'https://some_hopefully_nonexistant_domain:443/') 61 | assert resp.data == b'WSGI intercept successful!\n' 62 | assert app.success() 63 | 64 | 65 | def test_https_default_port(): 66 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 67 | resp = http.request( 68 | 'GET', 'https://some_hopefully_nonexistant_domain/') 69 | assert resp.data == b'WSGI intercept successful!\n' 70 | assert app.success() 71 | environ = app.get_internals() 72 | assert environ['wsgi.url_scheme'] == 'https' 73 | 74 | 75 | def test_socket_options(): 76 | http = urllib3.PoolManager(socket_options=[]) 77 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=80): 78 | http.request('GET', 'http://some_hopefully_nonexistant_domain/') 79 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443): 80 | http.request('GET', 'https://some_hopefully_nonexistant_domain/') 81 | 82 | 83 | def test_app_error(): 84 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 85 | with pytest.raises(WSGIAppError): 86 | http.request('GET', 'http://some_hopefully_nonexistant_domain/') 87 | 88 | 89 | @skipnetwork 90 | def test_http_not_intercepted(): 91 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 92 | resp = http.request('GET', 'http://google.com') 93 | assert resp.status >= 200 and resp.status < 300 94 | 95 | 96 | @skipnetwork 97 | def test_https_not_intercepted(): 98 | with InstalledApp(wsgi_app.raises_app, host=HOST, port=80): 99 | resp = http.request('GET', 'https://google.com') 100 | assert resp.status >= 200 and resp.status < 300 101 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_wsgi_compliance.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pytest 3 | try: 4 | from urllib.parse import unquote 5 | except ImportError: 6 | from urllib import unquote 7 | from wsgi_intercept import httplib2_intercept 8 | from . import wsgi_app 9 | from .install import installer_class 10 | import httplib2 11 | 12 | HOST = 'some_hopefully_nonexistant_domain' 13 | 14 | InstalledApp = installer_class(httplib2_intercept) 15 | 16 | 17 | def test_simple_override(): 18 | with InstalledApp(wsgi_app.simple_app, host=HOST) as app: 19 | http = httplib2.Http() 20 | resp, content = http.request( 21 | 'http://some_hopefully_nonexistant_domain:80/', 'GET') 22 | assert app.success() 23 | 24 | 25 | def test_simple_override_default_port(): 26 | with InstalledApp(wsgi_app.simple_app, host=HOST) as app: 27 | http = httplib2.Http() 28 | resp, content = http.request( 29 | 'http://some_hopefully_nonexistant_domain/', 'GET') 30 | assert app.success() 31 | 32 | 33 | def test_https_in_environ(): 34 | with InstalledApp(wsgi_app.simple_app, host=HOST, port=443) as app: 35 | http = httplib2.Http() 36 | resp, content = http.request( 37 | 'https://some_hopefully_nonexistant_domain/', 'GET') 38 | assert app.success() 39 | internal_env = app.get_internals() 40 | assert internal_env['wsgi.url_scheme'] == 'https' 41 | 42 | 43 | def test_more_interesting(): 44 | expected_uri = ('/%E4%B8%96%E4%B8%8A%E5%8E%9F%E4%BE%86%E9%82%84%E6' 45 | '%9C%89%E3%80%8C%E7%BE%9A%E7%89%9B%E3%80%8D%E9%80%99' 46 | '%E7%A8%AE%E5%8B%95%E7%89%A9%EF%BC%81%2Fbarney' 47 | '?bar=baz%20zoom') 48 | with InstalledApp(wsgi_app.more_interesting_app, host=HOST) as app: 49 | http = httplib2.Http() 50 | resp, content = http.request( 51 | 'http://some_hopefully_nonexistant_domain' + expected_uri, 52 | 'GET', 53 | headers={'Accept': 'application/json', 54 | 'Cookie': 'foo=bar'}) 55 | internal_env = app.get_internals() 56 | 57 | expected_path_info = unquote(expected_uri.split('?')[0]) 58 | assert internal_env['REQUEST_URI'] == expected_uri 59 | assert internal_env['RAW_URI'] == expected_uri 60 | assert internal_env['QUERY_STRING'] == 'bar=baz%20zoom' 61 | assert internal_env['HTTP_ACCEPT'] == 'application/json' 62 | assert internal_env['HTTP_COOKIE'] == 'foo=bar' 63 | # In this test we are ensuring the value, in the environ, of 64 | # a request header has a value which is a str, as native to 65 | # that version of Python. That means always a str, despite 66 | # the fact that a str in Python 2 and 3 are different 67 | # things! PEP 3333 requires this. isinstance is not used to 68 | # avoid confusion over class hierarchies. 69 | assert type(internal_env['HTTP_COOKIE']) == type('') # noqa E721 70 | 71 | # Do the rather painful wsgi encoding dance. 72 | if sys.version_info[0] > 2: 73 | assert internal_env['PATH_INFO'].encode('latin-1').decode( 74 | 'UTF-8') == expected_path_info 75 | else: 76 | assert internal_env['PATH_INFO'].decode( 77 | 'UTF-8') == expected_path_info.decode('UTF-8') 78 | 79 | 80 | def test_script_name(): 81 | with InstalledApp(wsgi_app.more_interesting_app, host=HOST, 82 | script_name='/funky') as app: 83 | http = httplib2.Http() 84 | response, content = http.request( 85 | 'http://some_hopefully_nonexistant_domain/funky/boom/baz') 86 | internal_env = app.get_internals() 87 | 88 | assert internal_env['SCRIPT_NAME'] == '/funky' 89 | assert internal_env['PATH_INFO'] == '/boom/baz' 90 | 91 | 92 | def test_encoding_errors(): 93 | with InstalledApp(wsgi_app.more_interesting_app, host=HOST): 94 | http = httplib2.Http() 95 | with pytest.raises(UnicodeEncodeError): 96 | response, content = http.request( 97 | 'http://some_hopefully_nonexistant_domain/boom/baz', 98 | headers={'Accept': u'application/\u2603'}) 99 | 100 | 101 | def test_post_status_headers(): 102 | with InstalledApp(wsgi_app.post_status_headers_app, host=HOST) as app: 103 | http = httplib2.Http() 104 | resp, content = http.request( 105 | 'http://some_hopefully_nonexistant_domain/', 'GET') 106 | assert app.success() 107 | assert resp.get('content-type') == 'text/plain' 108 | 109 | 110 | def test_empty_iterator(): 111 | with InstalledApp(wsgi_app.empty_string_app, host=HOST) as app: 112 | http = httplib2.Http() 113 | resp, content = http.request( 114 | 'http://some_hopefully_nonexistant_domain/', 'GET') 115 | assert app.success() 116 | assert content == b'second' 117 | 118 | 119 | def test_generator(): 120 | with InstalledApp(wsgi_app.generator_app, host=HOST) as app: 121 | http = httplib2.Http() 122 | resp, content = http.request( 123 | 'http://some_hopefully_nonexistant_domain/', 'GET') 124 | assert app.success() 125 | assert resp.get('content-type') == 'text/plain' 126 | assert content == b'First generated line\nSecond generated line\n' 127 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/test_wsgiref.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import wsgiref.simple_server 3 | 4 | from wsgi_intercept.interceptor import RequestsInterceptor 5 | 6 | 7 | def load_app(): 8 | return wsgiref.simple_server.demo_app 9 | 10 | 11 | def test_wsgiref(): 12 | """General test that the wsgiref server behaves. 13 | 14 | This mostly confirms that environ handling is correct in both 15 | python2 and 3. 16 | """ 17 | 18 | try: 19 | with RequestsInterceptor(load_app, host='www.example.net', port=80): 20 | r = requests.get('http://www.example.net') 21 | print(r.text) 22 | except Exception as exc: 23 | assert False, 'wsgi ref server raised exception: %s' % exc 24 | -------------------------------------------------------------------------------- /wsgi_intercept/tests/wsgi_app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple WSGI applications for testing. 3 | """ 4 | 5 | from pprint import pformat 6 | 7 | try: 8 | bytes 9 | except ImportError: 10 | bytes = str 11 | 12 | 13 | def simple_app(environ, start_response): 14 | """Simplest possible application object""" 15 | status = '200 OK' 16 | response_headers = [('Content-type', 'text/plain')] 17 | start_response(status, response_headers) 18 | return [b'WSGI intercept successful!\n'] 19 | 20 | 21 | def more_interesting_app(environ, start_response): 22 | start_response('200 OK', [('Content-type', 'text/plain')]) 23 | return [pformat(environ).encode('utf-8')] 24 | 25 | 26 | def post_status_headers_app(environ, start_response): 27 | headers = [] 28 | start_response('200 OK', headers) 29 | headers.append(('Content-type', 'text/plain')) 30 | return [b'WSGI intercept successful!\n'] 31 | 32 | 33 | def raises_app(environ, start_response): 34 | raise TypeError("bah") 35 | 36 | 37 | def empty_string_app(environ, start_response): 38 | start_response('200 OK', [('Content-type', 'text/plain')]) 39 | return [b'', b'second'] 40 | 41 | 42 | def generator_app(environ, start_response): 43 | start_response('200 OK', [('Content-type', 'text/plain')]) 44 | yield b'First generated line\n' 45 | yield b'Second generated line\n' 46 | -------------------------------------------------------------------------------- /wsgi_intercept/urllib3_intercept.py: -------------------------------------------------------------------------------- 1 | """Intercept HTTP connections that use 2 | `urllib3 `_. 3 | 4 | Note that currently only urllib3 <2.0.0 is supported. 2.0.0 support 5 | is in progress. 6 | """ 7 | 8 | from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool 9 | from urllib3.connection import HTTPConnection, HTTPSConnection 10 | from ._urllib3 import make_urllib3_override 11 | 12 | 13 | install, uninstall = make_urllib3_override(HTTPConnectionPool, 14 | HTTPSConnectionPool, 15 | HTTPConnection, 16 | HTTPSConnection) 17 | -------------------------------------------------------------------------------- /wsgi_intercept/urllib_intercept.py: -------------------------------------------------------------------------------- 1 | """Intercept HTTP connections that use urllib.request (Python 3) 2 | aka urllib2 (Python 2). 3 | """ 4 | 5 | import os 6 | 7 | try: 8 | import urllib.request as url_lib 9 | except ImportError: 10 | import urllib2 as url_lib 11 | 12 | from . import WSGI_HTTPConnection, WSGI_HTTPSConnection 13 | 14 | 15 | class WSGI_HTTPHandler(url_lib.HTTPHandler): 16 | """ 17 | Override the default HTTPHandler class with one that uses the 18 | WSGI_HTTPConnection class to open HTTP URLs. 19 | """ 20 | def http_open(self, req): 21 | return self.do_open(WSGI_HTTPConnection, req) 22 | 23 | 24 | class WSGI_HTTPSHandler(url_lib.HTTPSHandler): 25 | """ 26 | Override the default HTTPSHandler class with one that uses the 27 | WSGI_HTTPConnection class to open HTTPS URLs. 28 | """ 29 | def https_open(self, req): 30 | return self.do_open(WSGI_HTTPSConnection, req) 31 | 32 | 33 | def install_opener(): 34 | if 'http_proxy' in os.environ or 'https_proxy' in os.environ: 35 | raise RuntimeError( 36 | 'http_proxy or https_proxy set in environment, please unset') 37 | handlers = [WSGI_HTTPHandler()] 38 | if WSGI_HTTPSHandler is not None: 39 | handlers.append(WSGI_HTTPSHandler()) 40 | opener = url_lib.build_opener(*handlers) 41 | url_lib.install_opener(opener) 42 | 43 | return opener 44 | 45 | 46 | def uninstall_opener(): 47 | url_lib.install_opener(None) 48 | 49 | 50 | install = install_opener 51 | uninstall = uninstall_opener 52 | --------------------------------------------------------------------------------