spoof 2.2.2__tar.gz → 2.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spoof-2.2.2 → spoof-2.3.1}/CHANGELOG.rst +22 -0
- {spoof-2.2.2 → spoof-2.3.1}/PKG-INFO +19 -11
- {spoof-2.2.2 → spoof-2.3.1}/README.rst +16 -9
- {spoof-2.2.2 → spoof-2.3.1}/pyproject.toml +2 -2
- {spoof-2.2.2 → spoof-2.3.1}/src/spoof.egg-info/PKG-INFO +19 -11
- {spoof-2.2.2 → spoof-2.3.1}/src/spoof.py +78 -64
- {spoof-2.2.2 → spoof-2.3.1}/tests/test_spoof_functional.py +86 -17
- {spoof-2.2.2 → spoof-2.3.1}/.flake8 +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/.gitignore +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/.readthedocs.yaml +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/LICENSE +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/MANIFEST.in +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/Makefile +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/docs/Makefile +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/docs/conf.py +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/docs/index.rst +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/docs/requirements-docs.txt +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/requirements-dev.txt +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/setup.cfg +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/src/spoof.egg-info/SOURCES.txt +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/src/spoof.egg-info/dependency_links.txt +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/src/spoof.egg-info/top_level.txt +0 -0
- {spoof-2.2.2 → spoof-2.3.1}/tests/test_spoof_unit.py +0 -0
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
2.3.1 (2026-05-01)
|
|
2
|
+
==================
|
|
3
|
+
|
|
4
|
+
- Return ``self`` for ``.restart()`` method
|
|
5
|
+
- Update tests
|
|
6
|
+
- Update docs
|
|
7
|
+
|
|
8
|
+
2.3.0 (2026-04-27)
|
|
9
|
+
==================
|
|
10
|
+
|
|
11
|
+
- Add ``.restart()`` convenience method
|
|
12
|
+
- Add ``.serverAddress`` property to allow setting the server address
|
|
13
|
+
(requires restart after setting)
|
|
14
|
+
- Add ``.sslContext`` property to allow setting the sslContext
|
|
15
|
+
(requires restart after setting)
|
|
16
|
+
- Return ``None`` for server address, port, and URL if not started
|
|
17
|
+
- Return ``self`` for ``.start()`` method
|
|
18
|
+
- Apply least permission to GitHub Actions workflows
|
|
19
|
+
- Cleanup code
|
|
20
|
+
- Update tests
|
|
21
|
+
- Update docs
|
|
22
|
+
|
|
1
23
|
2.2.2 (2026-04-21)
|
|
2
24
|
==================
|
|
3
25
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spoof
|
|
3
|
-
Version: 2.
|
|
4
|
-
Summary: HTTP server for
|
|
3
|
+
Version: 2.3.1
|
|
4
|
+
Summary: A simple HTTP server for test environments
|
|
5
5
|
Author-email: Lex Scarisbrick <lex@scarisbrick.org>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Documentation, https://spoof.readthedocs.io/en/latest/
|
|
@@ -19,6 +19,7 @@ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
21
|
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
22
23
|
Classifier: Topic :: Software Development :: Testing :: Traffic Generation
|
|
23
24
|
Requires-Python: >=3.10
|
|
24
25
|
Description-Content-Type: text/x-rst
|
|
@@ -56,10 +57,10 @@ Spoof 👻
|
|
|
56
57
|
|
|
57
58
|
A test interface for HTTP
|
|
58
59
|
=========================
|
|
59
|
-
Spoof lets you easily create HTTP servers
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
Spoof lets you easily create HTTP servers on real network sockets.
|
|
61
|
+
Designed for test environments, what responses to send can be configured
|
|
62
|
+
anytime, including while an HTTP server is running. Requests can be
|
|
63
|
+
inspected live or after a response is sent.
|
|
63
64
|
|
|
64
65
|
Unlike a conventional HTTP server, where specific methods and paths are
|
|
65
66
|
configured in advance, Spoof accepts and records *all* requests, sending
|
|
@@ -72,7 +73,13 @@ HTTP client code. Have you ever felt icky patching a client library to
|
|
|
72
73
|
write tests? Ever been burned by this? Ever wanted to refactor a client
|
|
73
74
|
library, but had no way to verify behavior apart from doing live
|
|
74
75
|
integration testing? Ever wanted mock for HTTP? If you answered yes to
|
|
75
|
-
any of the above, Spoof might be for you.
|
|
76
|
+
any of the above, Spoof might be for you. Some key features:
|
|
77
|
+
|
|
78
|
+
* Decoupled requests and responses
|
|
79
|
+
* SSL/TLS with PQC
|
|
80
|
+
* HTTP/S proxy via CONNECT
|
|
81
|
+
* IPv6
|
|
82
|
+
* Live request debugging
|
|
76
83
|
|
|
77
84
|
Installation and Compatibility
|
|
78
85
|
==============================
|
|
@@ -84,7 +91,8 @@ Spoof is available on PyPI:
|
|
|
84
91
|
|
|
85
92
|
Spoof is tested on Python 3.10 to 3.14, leverages the ``http.server`` module
|
|
86
93
|
included in the Python standard library, and has no external dependencies.
|
|
87
|
-
It may work on older versions of Python, but this is
|
|
94
|
+
It may work on older versions of Python, but this is
|
|
95
|
+
`not supported <https://devguide.python.org/versions/>`__.
|
|
88
96
|
|
|
89
97
|
Multiple Spoof HTTP servers can be run concurrently, and by default, the port
|
|
90
98
|
number is the next available unused port. With OpenSSL installed, Spoof can
|
|
@@ -107,7 +115,7 @@ Spoof expects responses to have the following syntax:
|
|
|
107
115
|
# bytes content
|
|
108
116
|
[200, [("Content-Type", "application/json")], b'{"success": true }']
|
|
109
117
|
|
|
110
|
-
# responses can also be a callback
|
|
118
|
+
# responses can also be a callback, with request as the only argument
|
|
111
119
|
def callback(request):
|
|
112
120
|
return [200, [], request.path]
|
|
113
121
|
|
|
@@ -175,8 +183,8 @@ Request history
|
|
|
175
183
|
Spoof records each request and appends it to the ``.requests`` property,
|
|
176
184
|
which is backed by a
|
|
177
185
|
`deque <https://docs.python.org/3/library/collections.html#collections.deque>`__
|
|
178
|
-
instance, the same as the ``.responses`` property. Think of it like a
|
|
179
|
-
using request history:
|
|
186
|
+
instance, the same as the ``.responses`` property. Think of it like a structured
|
|
187
|
+
access log. Example using request history:
|
|
180
188
|
|
|
181
189
|
.. code-block:: python
|
|
182
190
|
|
|
@@ -29,10 +29,10 @@ Spoof 👻
|
|
|
29
29
|
|
|
30
30
|
A test interface for HTTP
|
|
31
31
|
=========================
|
|
32
|
-
Spoof lets you easily create HTTP servers
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
Spoof lets you easily create HTTP servers on real network sockets.
|
|
33
|
+
Designed for test environments, what responses to send can be configured
|
|
34
|
+
anytime, including while an HTTP server is running. Requests can be
|
|
35
|
+
inspected live or after a response is sent.
|
|
36
36
|
|
|
37
37
|
Unlike a conventional HTTP server, where specific methods and paths are
|
|
38
38
|
configured in advance, Spoof accepts and records *all* requests, sending
|
|
@@ -45,7 +45,13 @@ HTTP client code. Have you ever felt icky patching a client library to
|
|
|
45
45
|
write tests? Ever been burned by this? Ever wanted to refactor a client
|
|
46
46
|
library, but had no way to verify behavior apart from doing live
|
|
47
47
|
integration testing? Ever wanted mock for HTTP? If you answered yes to
|
|
48
|
-
any of the above, Spoof might be for you.
|
|
48
|
+
any of the above, Spoof might be for you. Some key features:
|
|
49
|
+
|
|
50
|
+
* Decoupled requests and responses
|
|
51
|
+
* SSL/TLS with PQC
|
|
52
|
+
* HTTP/S proxy via CONNECT
|
|
53
|
+
* IPv6
|
|
54
|
+
* Live request debugging
|
|
49
55
|
|
|
50
56
|
Installation and Compatibility
|
|
51
57
|
==============================
|
|
@@ -57,7 +63,8 @@ Spoof is available on PyPI:
|
|
|
57
63
|
|
|
58
64
|
Spoof is tested on Python 3.10 to 3.14, leverages the ``http.server`` module
|
|
59
65
|
included in the Python standard library, and has no external dependencies.
|
|
60
|
-
It may work on older versions of Python, but this is
|
|
66
|
+
It may work on older versions of Python, but this is
|
|
67
|
+
`not supported <https://devguide.python.org/versions/>`__.
|
|
61
68
|
|
|
62
69
|
Multiple Spoof HTTP servers can be run concurrently, and by default, the port
|
|
63
70
|
number is the next available unused port. With OpenSSL installed, Spoof can
|
|
@@ -80,7 +87,7 @@ Spoof expects responses to have the following syntax:
|
|
|
80
87
|
# bytes content
|
|
81
88
|
[200, [("Content-Type", "application/json")], b'{"success": true }']
|
|
82
89
|
|
|
83
|
-
# responses can also be a callback
|
|
90
|
+
# responses can also be a callback, with request as the only argument
|
|
84
91
|
def callback(request):
|
|
85
92
|
return [200, [], request.path]
|
|
86
93
|
|
|
@@ -148,8 +155,8 @@ Request history
|
|
|
148
155
|
Spoof records each request and appends it to the ``.requests`` property,
|
|
149
156
|
which is backed by a
|
|
150
157
|
`deque <https://docs.python.org/3/library/collections.html#collections.deque>`__
|
|
151
|
-
instance, the same as the ``.responses`` property. Think of it like a
|
|
152
|
-
using request history:
|
|
158
|
+
instance, the same as the ``.responses`` property. Think of it like a structured
|
|
159
|
+
access log. Example using request history:
|
|
153
160
|
|
|
154
161
|
.. code-block:: python
|
|
155
162
|
|
|
@@ -7,7 +7,7 @@ name = "spoof"
|
|
|
7
7
|
dynamic = ["version"]
|
|
8
8
|
requires-python = ">=3.10"
|
|
9
9
|
dependencies = []
|
|
10
|
-
description = "HTTP server for
|
|
10
|
+
description = "A simple HTTP server for test environments"
|
|
11
11
|
readme = "README.rst"
|
|
12
12
|
authors = [
|
|
13
13
|
{"name" = "Lex Scarisbrick", "email" = "lex@scarisbrick.org"},
|
|
@@ -25,6 +25,7 @@ classifiers = [
|
|
|
25
25
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
26
|
"Topic :: Software Development :: Quality Assurance",
|
|
27
27
|
"Topic :: Software Development :: Testing",
|
|
28
|
+
"Topic :: Software Development :: Testing :: Mocking",
|
|
28
29
|
"Topic :: Software Development :: Testing :: Traffic Generation",
|
|
29
30
|
]
|
|
30
31
|
|
|
@@ -44,7 +45,6 @@ py-modules = ["spoof"]
|
|
|
44
45
|
[tool.setuptools.packages.find]
|
|
45
46
|
where = ["src"]
|
|
46
47
|
include = ["spoof*"]
|
|
47
|
-
#exclude = [".github*", "tests*", "dist*", "build*", "*.egg-info*", "src/docs*"]
|
|
48
48
|
|
|
49
49
|
[tool.setuptools_scm]
|
|
50
50
|
# https://setuptools-scm.readthedocs.io/en/latest/extending/#setuptools_scmversion_scheme
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spoof
|
|
3
|
-
Version: 2.
|
|
4
|
-
Summary: HTTP server for
|
|
3
|
+
Version: 2.3.1
|
|
4
|
+
Summary: A simple HTTP server for test environments
|
|
5
5
|
Author-email: Lex Scarisbrick <lex@scarisbrick.org>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Documentation, https://spoof.readthedocs.io/en/latest/
|
|
@@ -19,6 +19,7 @@ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
21
|
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
22
23
|
Classifier: Topic :: Software Development :: Testing :: Traffic Generation
|
|
23
24
|
Requires-Python: >=3.10
|
|
24
25
|
Description-Content-Type: text/x-rst
|
|
@@ -56,10 +57,10 @@ Spoof 👻
|
|
|
56
57
|
|
|
57
58
|
A test interface for HTTP
|
|
58
59
|
=========================
|
|
59
|
-
Spoof lets you easily create HTTP servers
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
Spoof lets you easily create HTTP servers on real network sockets.
|
|
61
|
+
Designed for test environments, what responses to send can be configured
|
|
62
|
+
anytime, including while an HTTP server is running. Requests can be
|
|
63
|
+
inspected live or after a response is sent.
|
|
63
64
|
|
|
64
65
|
Unlike a conventional HTTP server, where specific methods and paths are
|
|
65
66
|
configured in advance, Spoof accepts and records *all* requests, sending
|
|
@@ -72,7 +73,13 @@ HTTP client code. Have you ever felt icky patching a client library to
|
|
|
72
73
|
write tests? Ever been burned by this? Ever wanted to refactor a client
|
|
73
74
|
library, but had no way to verify behavior apart from doing live
|
|
74
75
|
integration testing? Ever wanted mock for HTTP? If you answered yes to
|
|
75
|
-
any of the above, Spoof might be for you.
|
|
76
|
+
any of the above, Spoof might be for you. Some key features:
|
|
77
|
+
|
|
78
|
+
* Decoupled requests and responses
|
|
79
|
+
* SSL/TLS with PQC
|
|
80
|
+
* HTTP/S proxy via CONNECT
|
|
81
|
+
* IPv6
|
|
82
|
+
* Live request debugging
|
|
76
83
|
|
|
77
84
|
Installation and Compatibility
|
|
78
85
|
==============================
|
|
@@ -84,7 +91,8 @@ Spoof is available on PyPI:
|
|
|
84
91
|
|
|
85
92
|
Spoof is tested on Python 3.10 to 3.14, leverages the ``http.server`` module
|
|
86
93
|
included in the Python standard library, and has no external dependencies.
|
|
87
|
-
It may work on older versions of Python, but this is
|
|
94
|
+
It may work on older versions of Python, but this is
|
|
95
|
+
`not supported <https://devguide.python.org/versions/>`__.
|
|
88
96
|
|
|
89
97
|
Multiple Spoof HTTP servers can be run concurrently, and by default, the port
|
|
90
98
|
number is the next available unused port. With OpenSSL installed, Spoof can
|
|
@@ -107,7 +115,7 @@ Spoof expects responses to have the following syntax:
|
|
|
107
115
|
# bytes content
|
|
108
116
|
[200, [("Content-Type", "application/json")], b'{"success": true }']
|
|
109
117
|
|
|
110
|
-
# responses can also be a callback
|
|
118
|
+
# responses can also be a callback, with request as the only argument
|
|
111
119
|
def callback(request):
|
|
112
120
|
return [200, [], request.path]
|
|
113
121
|
|
|
@@ -175,8 +183,8 @@ Request history
|
|
|
175
183
|
Spoof records each request and appends it to the ``.requests`` property,
|
|
176
184
|
which is backed by a
|
|
177
185
|
`deque <https://docs.python.org/3/library/collections.html#collections.deque>`__
|
|
178
|
-
instance, the same as the ``.responses`` property. Think of it like a
|
|
179
|
-
using request history:
|
|
186
|
+
instance, the same as the ``.responses`` property. Think of it like a structured
|
|
187
|
+
access log. Example using request history:
|
|
180
188
|
|
|
181
189
|
.. code-block:: python
|
|
182
190
|
|
|
@@ -21,11 +21,11 @@ URI_QUERY_SEPARATOR = "?"
|
|
|
21
21
|
MEGABYTE = 2**20
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler
|
|
25
|
-
"""Provides HTTP handler for use with
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
|
|
25
|
+
"""Provides HTTP handler for use with ``http.server`` compatible class
|
|
26
|
+
server. Because the class is passed directly instead of an instance of
|
|
27
|
+
the class, the `*Queue` class attributes must be set before passing to
|
|
28
|
+
the HTTP server.
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
31
|
debug = False
|
|
@@ -33,7 +33,7 @@ class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler, object):
|
|
|
33
33
|
errorResponse = [
|
|
34
34
|
HTTP_SERVICE_UNAVAILABLE,
|
|
35
35
|
[],
|
|
36
|
-
"
|
|
36
|
+
"The .responses queue is empty and .defaultResponse is None\n\n",
|
|
37
37
|
]
|
|
38
38
|
maxRequestLength = 1 * MEGABYTE
|
|
39
39
|
proxyRequestGen = collections.namedtuple("SpoofProxyRequest", "thread run")
|
|
@@ -85,18 +85,21 @@ class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler, object):
|
|
|
85
85
|
response = self.errorResponse
|
|
86
86
|
return response
|
|
87
87
|
|
|
88
|
-
def
|
|
89
|
-
"""Sends response to HTTP client."""
|
|
90
|
-
statusCode, headers, content = response
|
|
91
|
-
|
|
88
|
+
def encodeResponseContent(self, content):
|
|
92
89
|
if content and isinstance(content, str):
|
|
93
90
|
content = content.encode(RESPONSE_ENCODING)
|
|
94
|
-
|
|
91
|
+
|
|
92
|
+
return content
|
|
93
|
+
|
|
94
|
+
def sendResponse(self, response):
|
|
95
|
+
"""Sends response to HTTP client."""
|
|
96
|
+
statusCode, headers, rawContent = response
|
|
97
|
+
content = self.encodeResponseContent(rawContent)
|
|
95
98
|
|
|
96
99
|
self.send_response(statusCode)
|
|
97
100
|
|
|
98
101
|
if content is not None:
|
|
99
|
-
self.send_header("Content-Length",
|
|
102
|
+
self.send_header("Content-Length", len(content))
|
|
100
103
|
for header in headers:
|
|
101
104
|
self.send_header(*header)
|
|
102
105
|
self.end_headers()
|
|
@@ -151,7 +154,7 @@ class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler, object):
|
|
|
151
154
|
def _proxyRequest(self, run):
|
|
152
155
|
downstream = self.request
|
|
153
156
|
upstream = socket.create_connection(
|
|
154
|
-
self.server.upstream.
|
|
157
|
+
(self.server.upstream.address, self.server.upstream.port), self.server.upstream.timeout
|
|
155
158
|
)
|
|
156
159
|
writer = {downstream: upstream, upstream: downstream}
|
|
157
160
|
|
|
@@ -176,7 +179,7 @@ class HTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler, object):
|
|
|
176
179
|
super(HTTPRequestHandler, self).log_message(*args, **kwargs)
|
|
177
180
|
|
|
178
181
|
|
|
179
|
-
class HTTPServer
|
|
182
|
+
class HTTPServer:
|
|
180
183
|
"""Provides a single-threaded HTTP testing server.
|
|
181
184
|
|
|
182
185
|
Class attributes:
|
|
@@ -199,53 +202,57 @@ class HTTPServer(object):
|
|
|
199
202
|
"""
|
|
200
203
|
self._requests = collections.deque()
|
|
201
204
|
self._responses = collections.deque()
|
|
202
|
-
self.
|
|
205
|
+
self._serverAddress = None
|
|
203
206
|
self._upstream = None
|
|
204
|
-
self.handlerClass = self.configureHandlerClass(
|
|
207
|
+
self.handlerClass = self.configureHandlerClass()
|
|
205
208
|
self.proxyMode = proxy
|
|
206
209
|
self.proxyThreads = []
|
|
207
210
|
self.recvSize = 4096
|
|
208
211
|
self.selectTimeout = 0.1
|
|
209
212
|
self.server = None
|
|
210
213
|
self.serverAddress = (host, port)
|
|
211
|
-
|
|
212
|
-
self.serverClass = HTTPServer6.configureServerClass(host)
|
|
213
|
-
else:
|
|
214
|
-
self.serverClass = self.configureServerClass(host)
|
|
215
|
-
self.serverClass.timeout = timeout
|
|
216
|
-
self.serverClass.sslContext = sslContext
|
|
214
|
+
self.sslContext = sslContext
|
|
217
215
|
self.thread = None
|
|
216
|
+
self.timeout = timeout
|
|
218
217
|
|
|
219
218
|
if self.proxyMode:
|
|
220
|
-
self.
|
|
221
|
-
|
|
219
|
+
self.setupDefaultUpstream()
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def serverAddress(self):
|
|
223
|
+
"""Returns address to bind for HTTP server."""
|
|
224
|
+
return self._serverAddress
|
|
225
|
+
|
|
226
|
+
@serverAddress.setter
|
|
227
|
+
def serverAddress(self, address):
|
|
228
|
+
"""Sets address to bind for HTTP server and setup server classes."""
|
|
229
|
+
self._serverAddress = address
|
|
230
|
+
self.serverClass = self.configureServerClass(address[0])
|
|
222
231
|
|
|
223
232
|
@property
|
|
224
233
|
def address(self):
|
|
225
|
-
"""Returns server IP/IPv6 address."""
|
|
226
|
-
return self.
|
|
234
|
+
"""Returns bound server IP/IPv6 address or ``None`` if unbound."""
|
|
235
|
+
return None if self.server is None else self.server.server_address[0]
|
|
227
236
|
|
|
228
237
|
@property
|
|
229
238
|
def port(self):
|
|
230
|
-
"""Returns server TCP port."""
|
|
231
|
-
return self.
|
|
239
|
+
"""Returns bound server TCP port or ``None`` if unbound."""
|
|
240
|
+
return None if self.server is None else self.server.server_address[1]
|
|
232
241
|
|
|
233
242
|
@property
|
|
234
243
|
def url(self):
|
|
235
|
-
"""Returns URL string
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
"""Returns `ssl.SSLContext` instance."""
|
|
243
|
-
return self._sslContext
|
|
244
|
+
"""Returns URL string for server instance or ``None`` if unbound."""
|
|
245
|
+
url = None
|
|
246
|
+
if self.server is not None:
|
|
247
|
+
protocol = "http" if self.sslContext is None else "https"
|
|
248
|
+
address = "[{0}]".format(self.address) if ":" in self.address else self.address
|
|
249
|
+
url = "{0}://{1}:{2}".format(protocol, address, self.port)
|
|
250
|
+
return url
|
|
244
251
|
|
|
245
252
|
@property
|
|
246
253
|
def timeout(self):
|
|
247
254
|
"""Returns HTTP server timeout."""
|
|
248
|
-
timeout = self.
|
|
255
|
+
timeout = self._timeout
|
|
249
256
|
if self.server is not None:
|
|
250
257
|
timeout = self.server.timeout
|
|
251
258
|
return timeout
|
|
@@ -253,7 +260,7 @@ class HTTPServer(object):
|
|
|
253
260
|
@timeout.setter
|
|
254
261
|
def timeout(self, value):
|
|
255
262
|
"""Sets HTTP server timeout."""
|
|
256
|
-
self.
|
|
263
|
+
self._timeout = value
|
|
257
264
|
if self.server is not None:
|
|
258
265
|
self.server.timeout = value
|
|
259
266
|
|
|
@@ -272,36 +279,47 @@ class HTTPServer(object):
|
|
|
272
279
|
if self.server is not None:
|
|
273
280
|
self.server.upstream = value
|
|
274
281
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
"""Reloads and configures server class. This is necessary, because of
|
|
282
|
+
def configureServerClass(self, host):
|
|
283
|
+
"""Reloads and configures server class. This is necessary because of
|
|
278
284
|
the use of class attributes. If more than one address family is used
|
|
279
285
|
concurrently (e.g. IPv4 _and_ IPv6), one will overwrite the other, as
|
|
280
286
|
the default behavior is for a class to be loaded once and only once,
|
|
281
287
|
including attributes. Using `type()` effectively creates a new class
|
|
282
|
-
with _discrete_ attributes.
|
|
283
|
-
called outside of the scope of an initialized class instance.
|
|
284
|
-
|
|
285
|
-
:host: hostname string of server
|
|
288
|
+
with _discrete_ attributes.
|
|
286
289
|
"""
|
|
287
|
-
sourceClass =
|
|
290
|
+
sourceClass = type(self).serverClass
|
|
288
291
|
serverClass = type(sourceClass.__name__, (sourceClass, object), dict())
|
|
289
|
-
serverClass.address_family =
|
|
292
|
+
serverClass.address_family = socket.AF_INET6 if host.count(":") > 1 else self.addressFamily
|
|
290
293
|
serverClass.serverName = host
|
|
291
294
|
return serverClass
|
|
292
295
|
|
|
293
|
-
def configureHandlerClass(self
|
|
296
|
+
def configureHandlerClass(self):
|
|
294
297
|
"""Reloads and configures handler class. This is necessary because of
|
|
295
298
|
the use of class attributes, which are necessary because the handler
|
|
296
299
|
class is instantiated anew to handle each request by `BaseServer`.
|
|
297
300
|
To keep the handler class unique to each `Spoof` instance, `type()` is
|
|
298
301
|
used to effectively create a discrete handler class and attributes.
|
|
299
302
|
"""
|
|
303
|
+
sourceClass = type(self).handlerClass
|
|
300
304
|
handlerClass = type(sourceClass.__name__, (sourceClass, object), dict())
|
|
301
305
|
handlerClass.responseContentQueue = self._responses
|
|
302
306
|
handlerClass.requestReportQueue = self._requests
|
|
303
307
|
return handlerClass
|
|
304
308
|
|
|
309
|
+
def setupDefaultUpstream(self):
|
|
310
|
+
"""Configures ready-to-use default upstream HTTP server."""
|
|
311
|
+
self.upstream = type(self)(
|
|
312
|
+
host="localhost", port=0, sslContext=self.sslContext, proxy=False
|
|
313
|
+
)
|
|
314
|
+
# RFC 7231, Section 4.3.6, "A server MUST NOT send any Transfer-Encoding or
|
|
315
|
+
# Content-Length header fields in a 2xx (Successful) response to CONNECT."
|
|
316
|
+
self.defaultResponse = [200, [], None]
|
|
317
|
+
|
|
318
|
+
def restart(self):
|
|
319
|
+
"""Stops and starts HTTP server."""
|
|
320
|
+
self.stop()
|
|
321
|
+
return self.start()
|
|
322
|
+
|
|
305
323
|
def start(self):
|
|
306
324
|
"""Starts HTTP server thread(s)."""
|
|
307
325
|
if self.server is not None:
|
|
@@ -311,15 +329,15 @@ class HTTPServer(object):
|
|
|
311
329
|
self.upstream.start()
|
|
312
330
|
|
|
313
331
|
self.server = self.serverClass(self.serverAddress, self.handlerClass)
|
|
314
|
-
self.
|
|
315
|
-
self.server.upstream = self.
|
|
316
|
-
if self.
|
|
317
|
-
self.server.socket = self.
|
|
318
|
-
|
|
319
|
-
)
|
|
332
|
+
self.server.timeout = self._timeout
|
|
333
|
+
self.server.upstream = self._upstream
|
|
334
|
+
if self.sslContext is not None:
|
|
335
|
+
self.server.socket = self.sslContext.wrap_socket(self.server.socket, server_side=True)
|
|
336
|
+
|
|
320
337
|
name = getattr(type(self), "__name__")
|
|
321
338
|
self.thread = threading.Thread(target=self.server.serve_forever, name=name)
|
|
322
339
|
self.thread.start()
|
|
340
|
+
return self
|
|
323
341
|
|
|
324
342
|
def stop(self):
|
|
325
343
|
"""Stops HTTP server thread(s) and closes socket(s)."""
|
|
@@ -340,19 +358,15 @@ class HTTPServer(object):
|
|
|
340
358
|
self.thread = None
|
|
341
359
|
|
|
342
360
|
def __enter__(self):
|
|
343
|
-
"""Starts
|
|
344
|
-
|
|
345
|
-
self.start()
|
|
346
|
-
return self
|
|
361
|
+
"""Starts and returns HTTP server instance when invoked as a context manager (with/as)."""
|
|
362
|
+
return self.start()
|
|
347
363
|
|
|
348
364
|
def __exit__(self, exceptionType, exceptionValue, traceback):
|
|
349
|
-
"""
|
|
350
|
-
If context block ends normally, all arguments will be `None`.
|
|
351
|
-
"""
|
|
365
|
+
"""Stops HTTP server instance when context manager block exits."""
|
|
352
366
|
self.stop()
|
|
353
367
|
|
|
354
368
|
def __del__(self):
|
|
355
|
-
"""
|
|
369
|
+
"""Stops HTTP server when instance goes out of scope."""
|
|
356
370
|
if getattr(self, "server", None) is not None:
|
|
357
371
|
self.stop()
|
|
358
372
|
|
|
@@ -468,7 +482,7 @@ class HTTPServer6(HTTPServer):
|
|
|
468
482
|
addressFamily = socket.AF_INET6
|
|
469
483
|
|
|
470
484
|
|
|
471
|
-
class SSLContext
|
|
485
|
+
class SSLContext:
|
|
472
486
|
"""Provides methods to create SSL context for use with `HTTPServer`."""
|
|
473
487
|
|
|
474
488
|
@staticmethod
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import itertools
|
|
1
2
|
import json
|
|
2
3
|
import os
|
|
3
4
|
import random
|
|
5
|
+
import socket
|
|
4
6
|
import ssl
|
|
5
7
|
import unittest
|
|
6
8
|
|
|
@@ -21,10 +23,8 @@ class TestRequest(BaseMixin):
|
|
|
21
23
|
def setUpClass(cls):
|
|
22
24
|
cls.selfSigned = spoof.SelfSignedSSLContext()
|
|
23
25
|
cls.sslContext = cls.selfSigned.sslContext
|
|
24
|
-
cls.httpd = spoof.HTTPServer(sslContext=cls.sslContext)
|
|
25
|
-
cls.httpd6 = spoof.HTTPServer6(
|
|
26
|
-
cls.httpd.start()
|
|
27
|
-
cls.httpd6.start()
|
|
26
|
+
cls.httpd = spoof.HTTPServer(sslContext=cls.sslContext).start()
|
|
27
|
+
cls.httpd6 = spoof.HTTPServer6(sslContext=cls.sslContext).start()
|
|
28
28
|
|
|
29
29
|
@classmethod
|
|
30
30
|
def tearDownClass(cls):
|
|
@@ -146,10 +146,13 @@ class TestRequest(BaseMixin):
|
|
|
146
146
|
self.assertEqual(request.status_code, errorResponse[0])
|
|
147
147
|
|
|
148
148
|
def test_spoof_selfSigned_raises_exception_on_connect(self):
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
# this kind of ssl http server setup has no way to access the underlying
|
|
150
|
+
# certificate and key files. the intent is to provide a real ssl context
|
|
151
|
+
# that is untrusted and kicked back as invalid, potentially to test out
|
|
152
|
+
# implementations that trust ssl connections that should not be trusted.
|
|
153
|
+
with spoof.HTTPServer(sslContext=spoof.SSLContext.selfSigned()) as httpd:
|
|
154
|
+
with self.assertRaises(requests.exceptions.SSLError):
|
|
155
|
+
self.session.get(httpd.url)
|
|
153
156
|
|
|
154
157
|
def test_spoof_allows_callable_defaultResponse(self):
|
|
155
158
|
status_code = random.randint(205, 299)
|
|
@@ -213,6 +216,75 @@ class TestRequest(BaseMixin):
|
|
|
213
216
|
self.assertEqual(expected_path, response.text)
|
|
214
217
|
self.assertEqual(expected_path, self.httpd.requests[-1].path)
|
|
215
218
|
|
|
219
|
+
def test_large_batch_queued_responses(self):
|
|
220
|
+
batchSize = 1_000
|
|
221
|
+
responses = [
|
|
222
|
+
[200, [("Content-Type", "application/json")], f'{{"seq": {seq}}}']
|
|
223
|
+
for seq in range(batchSize)
|
|
224
|
+
]
|
|
225
|
+
with spoof.HTTPServer() as httpd:
|
|
226
|
+
httpd.responses.extend(responses)
|
|
227
|
+
for seq in range(batchSize):
|
|
228
|
+
self.assertEqual({"seq": seq}, requests.get(httpd.url).json())
|
|
229
|
+
self.assertEqual(batchSize, len(httpd.requests))
|
|
230
|
+
|
|
231
|
+
def test_large_batch_generated_responses(self):
|
|
232
|
+
def responseGenerator():
|
|
233
|
+
for seq in itertools.count():
|
|
234
|
+
yield [200, [("Content-Type", "application/json")], json.dumps({"seq": seq})]
|
|
235
|
+
|
|
236
|
+
with spoof.HTTPServer() as httpd:
|
|
237
|
+
batchSize = 1_000
|
|
238
|
+
response = responseGenerator()
|
|
239
|
+
httpd.defaultResponse = lambda request: next(response)
|
|
240
|
+
for seq in range(batchSize):
|
|
241
|
+
self.assertEqual({"seq": seq}, self.session.get(httpd.url).json())
|
|
242
|
+
self.assertEqual(batchSize, len(httpd.requests))
|
|
243
|
+
|
|
244
|
+
def test_attrs_return_None_if_server_is_unbound(self):
|
|
245
|
+
httpd = spoof.HTTPServer()
|
|
246
|
+
self.assertIsNone(httpd.server)
|
|
247
|
+
self.assertIsNone(httpd.address)
|
|
248
|
+
self.assertIsNone(httpd.port)
|
|
249
|
+
self.assertIsNone(httpd.url)
|
|
250
|
+
|
|
251
|
+
def test_changing_sslContext_and_restarting(self):
|
|
252
|
+
expected = "set-sslcontext-live"
|
|
253
|
+
httpd = spoof.HTTPServer(sslContext=spoof.SSLContext.selfSigned()).start()
|
|
254
|
+
httpd.defaultResponse = [200, [], expected]
|
|
255
|
+
with self.assertRaises(requests.exceptions.SSLError):
|
|
256
|
+
requests.get(httpd.url)
|
|
257
|
+
|
|
258
|
+
httpd.sslContext = self.selfSigned.sslContext
|
|
259
|
+
httpd.restart()
|
|
260
|
+
result = requests.get(httpd.url, verify=self.selfSigned.certFile).text
|
|
261
|
+
self.assertTrue(httpd.url.startswith("https"))
|
|
262
|
+
self.assertEqual(expected, result)
|
|
263
|
+
|
|
264
|
+
def test_changing_serverAddress_and_restarting(self):
|
|
265
|
+
httpd = spoof.HTTPServer(host="127.0.0.1").start()
|
|
266
|
+
httpd.defaultResponse = lambda request: [200, [], request.serverName]
|
|
267
|
+
|
|
268
|
+
httpd.serverAddress = ("::1", 0)
|
|
269
|
+
httpd.restart()
|
|
270
|
+
self.assertEqual(requests.get(httpd.url).text, "::1")
|
|
271
|
+
self.assertEqual(httpd.server.socket.family, socket.AF_INET6)
|
|
272
|
+
|
|
273
|
+
httpd.serverAddress = ("127.0.0.1", 0)
|
|
274
|
+
httpd.restart()
|
|
275
|
+
self.assertEqual(requests.get(httpd.url).text, "127.0.0.1")
|
|
276
|
+
self.assertEqual(httpd.server.socket.family, socket.AF_INET)
|
|
277
|
+
|
|
278
|
+
def test_restart_returns_spoof_instance(self):
|
|
279
|
+
expected = httpd = spoof.HTTPServer()
|
|
280
|
+
result = httpd.restart()
|
|
281
|
+
self.assertEqual(expected, result)
|
|
282
|
+
|
|
283
|
+
def test_start_returns_spoof_instance(self):
|
|
284
|
+
expected = httpd = spoof.HTTPServer()
|
|
285
|
+
result = httpd.start()
|
|
286
|
+
self.assertEqual(expected, result)
|
|
287
|
+
|
|
216
288
|
|
|
217
289
|
class TestProxy(BaseMixin):
|
|
218
290
|
@classmethod
|
|
@@ -224,8 +296,7 @@ class TestProxy(BaseMixin):
|
|
|
224
296
|
cls.unlink(cls.cert, cls.key)
|
|
225
297
|
|
|
226
298
|
def setUp(self):
|
|
227
|
-
self.httpd = spoof.HTTPServer()
|
|
228
|
-
self.httpd.start()
|
|
299
|
+
self.httpd = spoof.HTTPServer().start()
|
|
229
300
|
self.session = requests.Session()
|
|
230
301
|
self.session.verify = self.cert
|
|
231
302
|
self.sslContext = spoof.SSLContext.fromCertChain(self.cert, self.key)
|
|
@@ -265,16 +336,14 @@ class TestProxy(BaseMixin):
|
|
|
265
336
|
self.assertEqual(expected, result)
|
|
266
337
|
|
|
267
338
|
def test_spoof_https_site_through_https_proxy(self):
|
|
268
|
-
# https proxies for https supported in requests v2.25.0 / urllib3 v1.26
|
|
269
339
|
expected = upstream_content = b"octet-comeback-squirmy"
|
|
270
|
-
httpd = spoof.HTTPServer(sslContext=self.sslContext)
|
|
340
|
+
httpd = spoof.HTTPServer(sslContext=self.sslContext).start()
|
|
271
341
|
httpd.defaultResponse = [200, [], None]
|
|
272
|
-
httpd.start()
|
|
273
|
-
httpd.upstream = spoof.HTTPServer(sslContext=self.sslContext)
|
|
342
|
+
httpd.upstream = spoof.HTTPServer(sslContext=self.sslContext).start()
|
|
274
343
|
httpd.upstream.defaultResponse = [200, [], upstream_content]
|
|
275
|
-
|
|
276
|
-
proxies
|
|
277
|
-
result = self.session.get(httpd.upstream.url, proxies=
|
|
344
|
+
|
|
345
|
+
# https proxies for https supported in requests v2.25.0 / urllib3 v1.26
|
|
346
|
+
result = self.session.get(httpd.upstream.url, proxies={"https": httpd.url}).content
|
|
278
347
|
self.assertTrue(httpd.upstream.url.startswith("https"))
|
|
279
348
|
self.assertTrue(httpd.url.startswith("https"))
|
|
280
349
|
self.assertEqual(expected, result)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|