A simple HTTP server for test environments
Project description
Spoof is a simple HTTP server for test environments.
>>> import requests
... import spoof
...
... with spoof.http() as http:
... http.responses.append([200, [], "This is Spoof 👻👋"])
... requests.get(http.url).text
...
'This is Spoof 👻👋'
A test interface for HTTP
Spoof lets you easily create HTTP servers on real network sockets. Designed for test environments, what responses to send can be configured anytime, including while an HTTP server is running. Requests can be inspected live or after a response is sent.
Unlike a conventional HTTP server, where specific methods and paths are configured in advance, Spoof accepts and records all requests, sending whatever responses are queued, or a default response if the queue is empty.
Why would I want this?
Spoof is all about enabling test-driven development (and refactoring) of HTTP client code. Have you ever felt icky patching a client library to write tests? Ever been burned by this? Ever wanted to refactor a client library, but had no way to verify behavior apart from doing live integration testing? Ever wanted mock for HTTP? If you answered yes to any of the above, Spoof might be for you. Some key features:
Decoupled requests and responses
SSL/TLS with PQC
HTTP/S proxy via CONNECT
IPv6
Live request debugging
Installation and Compatibility
Spoof is available on PyPI:
$ python -m pip install spoof
Spoof is tested on Python 3.10 to 3.14, leverages the http.server module included in the Python standard library, and has no external dependencies. It may work on older versions of Python, but this is not supported.
Multiple Spoof HTTP servers can be run concurrently, and by default, the port number is the next available unused port. With OpenSSL installed, Spoof can also provide an SSL/TLS HTTP server. HTTP proxying and IPv6 are also supported.
Response syntax
Spoof expects responses to have the following syntax:
[httpStatus, [(headerName1, value1), (headerName2, value2)], content]
# no content (Content-Length header is *not* sent if content is None)
[200, [], None]
# utf-8 content
[200, [], "This is Spoof 👻👋"]
# bytes content
[200, [("Content-Type", "application/json")], b'{"success": true }']
# responses can also be a callback, with request as the only argument
def callback(request):
return [200, [], request.path]
Response precedence
Spoof determines what response to send to incoming requests based on the following precedence, highest to lowest:
Oldest response queued in .responses using first-in, first-out (FIFO) order
Response stored in .defaultResponse if no responses are queued
Response stored in .errorResponse if .defaultResponse is None
By default, Spoof will respond with an HTTP 503 Service Unavailable error, because newly created Spoof instances have no responses queued and no default response set. This requires non-error HTTP responses to be explicitly specified.
Response queue
Spoof will always try to send a response from .responses first, before falling back to .defaultResponse if the queue is empty. Backed by a deque instance, the .responses queue supports adding items via .responses.append() and .responses.extend(), similar to a regular list.
Spoof HTTP servers run in a single background thread, so response order should be predictably serial. Tests using Spoof should be able to use the same fixtures, in the same order, and get the same results. Example queueing multiple responses, verifying content, and request paths:
import requests
import spoof
with spoof.http() as http:
http.responses.extend([
[200, [("Content-Type", "application/json")], b'{"id": 1111}'],
[200, [("Content-Type", "application/json")], b'{"id": 2222}'],
])
http.defaultResponse = [404, [], "Not found"]
assert requests.get(http.url + "/path").json() == {"id": 1111}
assert requests.get(http.url + "/alt/path").json() == {"id": 2222}
assert requests.get(http.url + "/oops").status_code == 404
assert [r.path for r in http.requests] == ["/path", "/alt/path", "/oops"]
Response default
Spoof will always try to send a response from .responses first, before falling back to .defaultResponse if the queue is empty. Example setting a callback as a default response:
import requests
import spoof
with spoof.http() as http:
http.defaultResponse = lambda request: [200, [], request.path]
assert requests.get(http.url + "/alt").text == "/alt"
Request history
Spoof records each request and appends it to the .requests property, which is backed by a deque instance, the same as the .responses property. Think of it like a structured access log. Example using request history:
>>> import requests
... import spoof
...
... with spoof.http() as http:
... http.defaultResponse = [200, [], None]
...
... [requests.get(http.url + path) for path in ["/a", "/b", "/c"]]
... [f"{r.method} {r.path} {r.protocol}" for r in http.requests]
...
[<Response [200]>, <Response [200]>, <Response [200]>]
['GET /a HTTP/1.1', 'GET /b HTTP/1.1', 'GET /c HTTP/1.1']
Request properties
SpoofRequestEnv instances have the following properties:
Property |
Description |
|---|---|
content |
bytes object of request content |
contentEncoding |
Value of Content-Encoding header, if present |
contentLength |
Value of Content-Length header, if present |
contentType |
Value of Content-Type header, if present |
headers |
http.client.HTTPMessage object of headers |
json() |
Convenience to call json.loads on content |
method |
Request method (e.g. GET, POST, HEAD) |
path |
Decoded URI path, without query string |
protocol |
Protocol version (e.g. HTTP/1.0) |
queryString |
Anything in URI after ? |
serverName |
Host name of HTTP server |
serverPort |
Port number of HTTP server |
uri |
Raw URI path and query string, if present |
SSL/TLS Mode
Spoof supports SSL/TLS connectivity by passing an SSLContext, or if OpenSSL command line tools are available, creating an SSLContext with a self-signed certificate. Configured correctly, this should not raise any warnings or errors:
import requests
import spoof
with spoof.http(ssl=True) as http:
http.responses.append([200, [], "No self-signed cert warning!"])
response = requests.get(http.url, verify=http.ssl.certFile)
assert response.text == "No self-signed cert warning!"
If setting the verify option in requests isn’t workable, the REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE environment variables can be set to the path of the self-signed certificate to silence SSL/TLS errors:
import os
import requests
import spoof
with spoof.http(ssl=True) as http:
http.responses.append([200, [], "No self-signed cert warning!"])
os.environ["REQUESTS_CA_BUNDLE"] = http.ssl.certFile
response = requests.get(http.url)
assert response.text == "No self-signed cert warning!"
If OpenSSL 3.5.0 or later is installed, Post-Quantum Cryptography (PQC) key algorithms can be used:
import requests
import spoof
with spoof.ssl(keyAlgorithm="mldsa65") as ssl:
with spoof.http(ssl=ssl) as http:
http.responses.append([200, [], "TLS with PQC Key Algorithm"])
response = requests.get(http.url, verify=ssl.certFile)
assert response.text == "TLS with PQC Key Algorithm"
Proxy Mode
Spoof supports proxying by port-forwarding CONNECT requests to a separate upstream Spoof instance when the proxy=True argument is given. Unlike a real proxy server, Spoof won’t try to connect to external services. Example usage:
import requests
import spoof
with spoof.ssl(commonName="example.spoof") as ssl:
with spoof.http(ssl=ssl, proxy=True) as proxy:
proxy.upstream.defaultResponse = [200, [], "I'm here!"]
response = requests.get(
"https://example.spoof/ayt",
proxies={"https": proxy.url},
verify=ssl.certFile
)
assert proxy.requests[0].method == "CONNECT"
assert proxy.requests[0].path == "example.spoof:443"
assert proxy.upstream.requests[0].method == "GET"
assert proxy.upstream.requests[0].path == "/ayt"
assert response.text == "I'm here!"
If setting the proxies option in requests isn’t workable, the https_proxy environment variable can be set to the URL of the proxy:
import os
import requests
import spoof
with spoof.ssl(commonName="example.spoof") as ssl:
with spoof.http(ssl=ssl, proxy=True) as proxy:
proxy.upstream.defaultResponse = [200, [], "I'm here!"]
os.environ["https_proxy"] = proxy.url
os.environ["REQUESTS_CA_BUNDLE"] = ssl.certFile
response = requests.get("https://example.spoof/ayt")
assert proxy.requests[0].method == "CONNECT"
assert proxy.requests[0].path == "example.spoof:443"
assert proxy.upstream.requests[0].method == "GET"
assert proxy.upstream.requests[0].path == "/ayt"
assert response.text == "I'm here!"
IPv6 Mode
Setting the host attribute to an IPv6 address will work as expected. There is also an IPv6-only spoof.http6 class that can be used if needed to only listen on IPv6 sockets.
>>> import requests
... import spoof
...
... with spoof.http(host="::1") as http:
... http.responses.append([200, [], "This is Spoof on IPv6 👀"])
... requests.get(http.url).text
... http.url
...
'This is Spoof on IPv6 👀'
'http://[::1]:51324'
>>> import requests
... import spoof
...
... with spoof.http6(host="localhost") as http:
... http.responses.append([200, [], "This is also Spoof on IPv6 👀"])
... requests.get(http.url).text
... http.url
...
'This is also Spoof on IPv6 👀'
'http://[::1]:54296'
Debug mode
Setting a callback with a breakpoint() can allow for live HTTP request debugging, including setting custom responses and inspecting requests. Note that callbacks can also be queued.
>>> import requests
... import spoof
...
... def debugCallback(request):
... response = [200, [], ""]
... breakpoint()
... return response
...
... with spoof.http() as http:
... http.defaultResponse = debugCallback
... requests.get(http.url).text
...
> <python-input-0>(6)debugCallback()
(Pdb) request
SpoofRequestEnv(content=None, contentEncoding=None, contentLength=0, contentType=None, headers=<http.client.HTTPMessage object at 0x10e16bd90>, method='GET', path='/', protocol='HTTP/1.1', queryString=None, serverName='localhost', serverPort=51612, uri='/')
(Pdb) response[2] = "content set from pdb"
(Pdb) c
'content set from pdb'
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file spoof-2.4.0.tar.gz.
File metadata
- Download URL: spoof-2.4.0.tar.gz
- Upload date:
- Size: 26.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bab527c71f50258e555cb714a4ced65708f3c2590ece5250df46610320e167eb
|
|
| MD5 |
393535cd5792fced1b7f2c3659318087
|
|
| BLAKE2b-256 |
d426cf108f2473a542288df96d788bd49acd0ebb8dea30b8c1afed29e33a877b
|
Provenance
The following attestation bundles were made for spoof-2.4.0.tar.gz:
Publisher:
release.yml on lexsca/spoof
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spoof-2.4.0.tar.gz -
Subject digest:
bab527c71f50258e555cb714a4ced65708f3c2590ece5250df46610320e167eb - Sigstore transparency entry: 1438512687
- Sigstore integration time:
-
Permalink:
lexsca/spoof@766981bf725f110f8a5970d135283518464f80a9 -
Branch / Tag:
refs/tags/v2.4.0 - Owner: https://github.com/lexsca
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@766981bf725f110f8a5970d135283518464f80a9 -
Trigger Event:
release
-
Statement type:
File details
Details for the file spoof-2.4.0-py3-none-any.whl.
File metadata
- Download URL: spoof-2.4.0-py3-none-any.whl
- Upload date:
- Size: 12.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a9469706be7645925fda7a028f4e921bd0f40317eb2891eb89f14cdbbfc125ff
|
|
| MD5 |
b08801b35636c1cd42db457add24b77d
|
|
| BLAKE2b-256 |
89ef64ea31ef8b58e7c0af942fb28bee6b32f77d10ae71a2dabc26f0e8f81b63
|
Provenance
The following attestation bundles were made for spoof-2.4.0-py3-none-any.whl:
Publisher:
release.yml on lexsca/spoof
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spoof-2.4.0-py3-none-any.whl -
Subject digest:
a9469706be7645925fda7a028f4e921bd0f40317eb2891eb89f14cdbbfc125ff - Sigstore transparency entry: 1438512725
- Sigstore integration time:
-
Permalink:
lexsca/spoof@766981bf725f110f8a5970d135283518464f80a9 -
Branch / Tag:
refs/tags/v2.4.0 - Owner: https://github.com/lexsca
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@766981bf725f110f8a5970d135283518464f80a9 -
Trigger Event:
release
-
Statement type: