Skip to content

Commit 9a07fa8

Browse files
committed
Use httpx.WSGITransport for wsgi app interception
Replace wsgi-intercept, used in gabbi as a feature to install the intercept and do http over a monkey- patched socker. The replacement is WSGITransport from httpx. This works in a different way. Instead of monkey-patching, a different transport is dependency-injected into the http client. Each gabbi tests has its own client instance. Because of this change in concept the diff, though not especially big, touches several parts of the code. 1. Fixture handling removes use of the wsgi-intecept context manager. 2. intercept and prefix are based to httpclient generation. intercept signals that the WSGITransport should be used. 3. An external server process (in tests/external_server.py) is used by tests/test_runner.py so that gabbi-run can be tested effectively without wsgi-intercept. 4. Step 3 identified some long present bugs in the SimpleWSGI test app to do with reading content from POST and PUT operations. 5. Prefix handling and url-generation are different when using WSGITransport so simplewsgi has a different way of constructing fully qualified URLs. This uses removeprefix which is no supported in python 3.8, so support for 3.8 is dropped. 3.8 was EOL in 2024. 6. Special tests which had all been depdending on the older python 3.9 now use 3.13. This is mostly because that made it is easier for cdent to do testing locally, but seems good hygiene anyway. 7. pypy3 is upgrade to pypy3.10 as that's what's available these days. 8. Docs have tried to be updated but it is likelys something has been missed. 9. Some shenanigans with how hostnames are handled in SimpleWSGI needed to be reconciled between what a mac does and what a github ci node does. This patch does _not_ try to prepare a release. That will be later.
1 parent f92f6bf commit 9a07fa8

15 files changed

+219
-206
lines changed

.github/workflows/tests.yaml

+7-11
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ jobs:
1111
include:
1212
- python: 3.x
1313
toxenv: pep8
14-
- python: 3.8
15-
toxenv: py38
1614
- python: 3.9
1715
toxenv: py39
1816
- python: "3.10"
@@ -23,10 +21,8 @@ jobs:
2321
toxenv: py312
2422
- python: "3.13"
2523
toxenv: py313
26-
- python: pypy-3.8
24+
- python: pypy-3.10
2725
toxenv: pypy3
28-
- python: 3.8
29-
toxenv: py38-pytest
3026
- python: 3.9
3127
toxenv: py39-pytest
3228
- python: "3.10"
@@ -37,12 +33,12 @@ jobs:
3733
toxenv: py312-pytest
3834
- python: "3.13"
3935
toxenv: py313-pytest
40-
- python: 3.9
41-
toxenv: py39-failskip
42-
- python: 3.9
43-
toxenv: py39-limit
44-
- python: 3.9
45-
toxenv: py39-prefix
36+
- python: "3.13"
37+
toxenv: py313-failskip
38+
- python: "3.13"
39+
toxenv: py313-limit
40+
- python: "3.13"
41+
toxenv: py313-prefix
4642
name: ${{ matrix.toxenv }} on Python ${{ matrix.python }}
4743
steps:
4844
- uses: actions/checkout@v2

README.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ looks like this::
2020
See the docs_ for more details on the many features and formats for
2121
setting request headers and bodies and evaluating responses.
2222

23-
Gabbi is tested with Python 3.8, 3.9, 3.10, 3.11, 3.12, 3.13 and pypy3.
23+
Gabbi is tested with Python 3.9, 3.10, 3.11, 3.12, 3.13 and pypy3.10.
2424

2525
Tests can be run using `unittest`_ style test runners, `pytest`_
2626
or from the command line with a `gabbi-run`_ script.

docs/source/host.rst

+8-9
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@ Gabbi intends to preserve the flow and semantics of HTTP interactions
66
as much as possible, and every HTTP request needs to be directed at a host
77
of some form. Gabbi provides three ways to control this:
88

9-
* Using `wsgi-intercept`_ to provide a fake socket and ``WSGI``
10-
environment on an arbitrary host and port attached to a ``WSGI``
11-
application (see `intercept examples`_).
9+
* Using `WSGITransport` of httpx to provide a ``WSGI`` environment on
10+
directly attached to a ``WSGI`` application (see `intercept examples`_).
1211
* Using fully qualified ``url`` values in the YAML defined tests (see
1312
`full examples`_).
1413
* Using a host and (optionally) port defined at test build time (see
@@ -18,15 +17,15 @@ The intercept and live methods are mutually exclusive per test builder,
1817
but either kind of test can freely intermix fully qualified URLs into the
1918
sequence of tests in a YAML file.
2019

21-
For test driven development and local tests the intercept style of
22-
testing lowers test requirements (no web server required) and is fast.
23-
Interception is performed as part of :doc:`fixtures` processing as the most
24-
deeply nested fixture. This allows any configuration or database
25-
setup to be performed prior to the WSGI application being created.
20+
For Python-based test driven development and local tests the intercept
21+
style of testing lowers test requirements (no web server required) and
22+
is fast. Interception is performed as part of the per-test-case http
23+
client. Configuration or database setup may be performed using
24+
:doc:`fixtures`.
2625

2726
For the implementation of the above see :meth:`~gabbi.driver.build_tests`.
2827

29-
.. _wsgi-intercept: https://pypi.python.org/pypi/wsgi_intercept
28+
.. _WSGITransport: https://www.python-httpx.org/advanced/transports/#wsgi-transport
3029
.. _intercept examples: https://github.com/cdent/gabbi/blob/main/gabbi/tests/test_intercept.py
3130
.. _full examples: https://github.com/cdent/gabbi/blob/main/gabbi/tests/gabbits_live/google.yaml
3231
.. _live examples: https://github.com/cdent/gabbi/blob/main/gabbi/tests/test_live.py

gabbi/case.py

+9-21
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@
2929
from unittest import result as unitresult
3030
import urllib.parse as urlparse
3131

32-
import wsgi_intercept
33-
3432
from gabbi import __version__
3533
from gabbi import exception
3634
from gabbi.handlers import base
@@ -492,29 +490,19 @@ def _run_request(
492490
redirect=False,
493491
timeout=30,
494492
):
495-
"""Run the http request and decode output.
496-
497-
The call to make the request will catch a WSGIAppError from
498-
wsgi_intercept so that the real traceback from a catastrophic
499-
error in the intercepted app can be examined.
500-
"""
493+
"""Run the http request and decode output."""
501494

502495
if 'user-agent' not in (key.lower() for key in headers):
503496
headers['user-agent'] = "gabbi/%s (Python httpx)" % __version__
504497

505-
try:
506-
response, content = self.http.request(
507-
url,
508-
method=method,
509-
headers=headers,
510-
body=body,
511-
redirect=redirect,
512-
timeout=timeout,
513-
)
514-
except wsgi_intercept.WSGIAppError as exc:
515-
# Extract and re-raise the wrapped exception.
516-
raise exc.exception_type(exc.exception_value).with_traceback(
517-
exc.traceback) from None
498+
response, content = self.http.request(
499+
url,
500+
method=method,
501+
headers=headers,
502+
body=body,
503+
redirect=redirect,
504+
timeout=timeout,
505+
)
518506

519507
# Set headers and location attributes for follow on requests
520508
self.response = response

gabbi/driver.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def build_tests(path, loader, host=None, port=8001, intercept=None,
5252
:param loader: The TestLoader.
5353
:param host: The host to test against. Do not use with ``intercept``.
5454
:param port: The port to test against. Used with ``host``.
55-
:param intercept: WSGI app factory for wsgi-intercept.
55+
:param intercept: WSGI app factory for httpclient WSGITransport.
5656
:param test_loader_name: Base name for test classes. Use this to align the
5757
naming of the tests with other tests in a system.
5858
:param fixture_module: Python module containing fixture classes.
@@ -158,7 +158,7 @@ def build_tests(path, loader, host=None, port=8001, intercept=None,
158158

159159

160160
def py_test_generator(test_dir, host=None, port=8001, intercept=None,
161-
prefix=None, test_loader_name=None,
161+
prefix='', test_loader_name=None,
162162
fixture_module=None, response_handlers=None,
163163
content_handlers=None, require_ssl=False, url=None,
164164
metafunc=None, use_prior_test=True,

gabbi/httpclient.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,14 @@ def __init__(self, **kwargs):
3434
self.extensions = {}
3535
if 'server_hostname' in kwargs:
3636
self.extensions['sni_hostname'] = kwargs['server_hostname']
37-
self.client = httpx.Client(verify=kwargs.get('cert_validate', True))
37+
transport = kwargs.get('intercept')
38+
if transport:
39+
transport = httpx.WSGITransport(
40+
app=transport(), script_name=kwargs.get("prefix", "")
41+
)
42+
self.client = httpx.Client(
43+
transport=transport, verify=kwargs.get("cert_validate", True)
44+
)
3845

3946
def request(self, absolute_uri, method, body, headers, redirect, timeout):
4047
response = self.client.request(
@@ -191,6 +198,8 @@ def get_http(
191198
caption='',
192199
cert_validate=True,
193200
hostname=None,
201+
intercept=None,
202+
prefix='',
194203
timeout=30,
195204
):
196205
"""Return an ``Http`` class for making requests."""
@@ -199,6 +208,8 @@ def get_http(
199208
server_hostname=hostname,
200209
timeout=timeout,
201210
cert_validate=cert_validate,
211+
intercept=intercept,
212+
prefix=prefix,
202213
)
203214

204215
headers = verbose != 'body'
@@ -213,4 +224,6 @@ def get_http(
213224
server_hostname=hostname,
214225
timeout=timeout,
215226
cert_validate=cert_validate,
227+
intercept=intercept,
228+
prefix=prefix,
216229
)

gabbi/suite.py

+7-27
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@
1919
import sys
2020
import unittest
2121

22-
from wsgi_intercept import interceptor
23-
2422
from gabbi import fixture
2523

2624

@@ -46,16 +44,11 @@ def run(self, result, debug=False):
4644
are no fixtures.
4745
"""
4846

49-
fixtures, intercept, host, port, prefix = self._get_intercept()
47+
fixtures, host, port = self._get_fixtures()
5048

5149
try:
5250
with fixture.nest([fix() for fix in fixtures]):
53-
if intercept:
54-
with interceptor.Urllib3Interceptor(
55-
intercept, host, port, prefix):
56-
result = super(GabbiSuite, self).run(result, debug)
57-
else:
58-
result = super(GabbiSuite, self).run(result, debug)
51+
result = super(GabbiSuite, self).run(result, debug)
5952
except unittest.SkipTest as exc:
6053
for test in self._tests:
6154
result.addSkip(test, str(exc))
@@ -85,7 +78,7 @@ def run(self, result, debug=False):
8578
def start(self, result, tests=None):
8679
"""Start fixtures when using pytest."""
8780
tests = tests or []
88-
fixtures, intercept, host, port, prefix = self._get_intercept()
81+
fixtures, host, port = self._get_fixtures()
8982

9083
self.used_fixtures = []
9184
try:
@@ -100,38 +93,25 @@ def start(self, result, tests=None):
10093
test.run = noop
10194
test.add_marker('skip')
10295
result.addSkip(self, str(exc))
103-
if intercept:
104-
intercept_fixture = interceptor.Urllib3Interceptor(
105-
intercept, host, port, prefix)
106-
intercept_fixture.__enter__()
107-
self.used_fixtures.append(intercept_fixture)
10896

10997
def stop(self):
11098
"""Stop fixtures when using pytest."""
11199
for fix in reversed(self.used_fixtures):
112100
fix.__exit__(None, None, None)
113101

114-
def _get_intercept(self):
102+
def _get_fixtures(self):
115103
fixtures = [fixture.GabbiFixture]
116-
intercept = host = port = prefix = None
104+
host = port = None
117105
try:
118106
first_test = self._find_first_full_test()
119107
fixtures = first_test.fixtures
120108
host = first_test.host
121109
port = first_test.port
122-
prefix = first_test.prefix
123-
intercept = first_test.intercept
124-
125-
# Unbind a passed in WSGI application. During the
126-
# metaclass building process intercept becomes bound.
127-
try:
128-
intercept = intercept.__func__
129-
except AttributeError:
130-
pass
110+
131111
except AttributeError:
132112
pass
133113

134-
return fixtures, intercept, host, port, prefix
114+
return fixtures, host, port
135115

136116
def _find_first_full_test(self):
137117
"""Traverse a sparse test suite to find the first HTTPTestCase.

gabbi/suitemaker.py

+2
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def make_one_test(self, test_dict, prior_test):
9090
caption=test['name'],
9191
cert_validate=test['cert_validate'],
9292
hostname=hostname,
93+
intercept=self.intercept,
94+
prefix=self.prefix,
9395
timeout=int(test["timeout"]))
9496
if prior_test:
9597
history = prior_test.history

gabbi/tests/external_server.py

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#
2+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
# not use this file except in compliance with the License. You may obtain
4+
# a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
# License for the specific language governing permissions and limitations
12+
# under the License.
13+
14+
import sys
15+
from wsgiref import simple_server
16+
17+
from gabbi.tests import simple_wsgi
18+
19+
20+
def run(host, port):
21+
server = simple_server.make_server(
22+
host,
23+
int(port),
24+
simple_wsgi.SimpleWsgi(),
25+
)
26+
server.serve_forever()
27+
28+
29+
if __name__ == "__main__":
30+
host = sys.argv[1]
31+
port = sys.argv[2]
32+
run(host, port)

gabbi/tests/gabbits_intercept/host-header.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Test, against wsgi-intercept, that SNI and host header handling behaves.
1+
# Test, against intercepted WSGI app, that SNI and host header handling behaves.
22

33
tests:
44

gabbi/tests/simple_wsgi.py

+15-9
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,22 @@ def __call__(self, environ, start_response):
3030
global METHODS
3131
global CURRENT_POLL
3232

33+
script_name = environ.get('SCRIPT_NAME', '')
34+
path_info = environ.get('PATH_INFO', '').removeprefix(script_name)
3335
request_method = environ['REQUEST_METHOD'].upper()
34-
query_data = urlparse.parse_qs(environ.get('QUERY_STRING', ''))
35-
request_url = environ.get('REQUEST_URI',
36-
environ.get('RAW_URI', 'unknown'))
37-
path_info = environ.get('PATH_INFO', '')
36+
query_string = environ.get('QUERY_STRING', '')
37+
query_data = urlparse.parse_qs(query_string)
38+
request_url = script_name + path_info
3839
accept_header = environ.get('HTTP_ACCEPT')
3940
content_type_header = environ.get('CONTENT_TYPE', '')
4041

41-
full_request_url = self._fully_qualify(environ, request_url)
42+
full_request_url = self._fully_qualify(
43+
environ,
44+
request_url,
45+
query_string,
46+
)
4247

43-
if accept_header:
48+
if accept_header and accept_header != '*/*':
4449
response_content_type = accept_header
4550
else:
4651
# JSON doesn't need a charset but we throw one in here
@@ -64,7 +69,8 @@ def __call__(self, environ, start_response):
6469
return []
6570

6671
if request_method.startswith('P'):
67-
body = environ['wsgi.input'].read()
72+
length = int(environ.get('CONTENT_LENGTH', '0'))
73+
body = environ['wsgi.input'].read(length)
6874
if body:
6975
if not content_type_header:
7076
start_response('400 Bad request', headers)
@@ -147,7 +153,7 @@ def __call__(self, environ, start_response):
147153
return [query_output.encode('utf-8')]
148154

149155
@staticmethod
150-
def _fully_qualify(environ, url):
156+
def _fully_qualify(environ, url, query_data):
151157
"""Turn a URL path into a fully qualified URL."""
152158
split_url = urlparse.urlsplit(url)
153159
server_name = environ.get('SERVER_NAME')
@@ -159,4 +165,4 @@ def _fully_qualify(environ, url):
159165
netloc = server_name
160166

161167
return urlparse.urlunsplit((server_scheme, netloc, split_url.path,
162-
split_url.query, split_url.fragment))
168+
query_data, split_url.fragment))

0 commit comments

Comments
 (0)