blackbull.client¶
blackbull.client
¶
Abort
dataclass
¶
Hard-close the connection (transport.abort → RST on Linux).
Distinct from the graceful writer.close() / wait_closed()
in HTTP1Client.__aexit__. Subsequent steps short-circuit; the
executor stops walking the scenario after an Abort.
Client
¶
ALPN-negotiating client.
Picks HTTP2Client if the server advertises h2, else HTTP1Client.
With ssl=None (the default), no ALPN is performed and HTTP/1.1 is used
— h2c (HTTP/2 over plaintext) is supported by HTTP2Client directly,
but the dispatcher only triggers it when ALPN selects h2.
Use as an async context manager::
async with Client('localhost', 8000) as c:
res = await c.request(HTTPMethod.GET, '/')
# `c` is an HTTP1Client when ssl=None, or whichever the ALPN handshake chose.
ClientError
¶
Bases: Exception
Base class for all client-side errors.
ClientResponse
dataclass
¶
A complete HTTP response received by the client.
status is the HTTP status code (parsed from the :status pseudo-header).
headers are the regular response headers as a Headers instance
(bytes-keyed, lowercase-indexed). body is the concatenation of all
DATA-frame payloads received on the stream.
ConnectionError
¶
Bases: ClientError
The connection was closed unexpectedly (e.g. server sent GOAWAY).
HTTP1Client
¶
Async HTTP/1.1 client.
Use as an async context manager::
async with HTTP1Client('localhost', 8000) as c:
res = await c.request(HTTPMethod.GET, '/path')
The connection persists across multiple request() calls (HTTP/1.1
persistent connections, RFC 7230 §6.3) until __aexit__ closes it.
Pass ssl= to use TLS.
The Host header is injected automatically when the caller omits it.
wire_buffer
property
¶
Bytes sent so far by the low-level primitives in this session.
Empty unless the client was constructed with
record_wire_bytes=True. Reset with :meth:reset_wire_buffer.
end_chunked()
async
¶
Emit the size-0 terminator chunk that closes a chunked body.
end_headers()
async
¶
Emit the bare CRLF that terminates the header block.
execute_scenario(scenario)
async
¶
Walk scenario.steps against the connected socket.
Never raises. Every outcome (response, timeout, transport
failure, hard-abort) is folded into the returned
:class:ScenarioResult so callers can categorise without
try/except boilerplate per scenario.
Step dispatch
- :class:
SendBytes→ :meth:send_raw - :class:
Sleep→ :func:asyncio.sleep - :class:
ReadResponse→ :meth:read_response - :class:
Abort→transport.abort()(RST on Linux); walks no further steps.
read_response(*, timeout=None)
async
¶
Read one HTTP/1.1 response from the connection.
Optional timeout bounds the entire read (status line + headers
+ body). Raises :class:asyncio.TimeoutError if the deadline is
hit; the caller decides whether to treat that as a transport-
fail or a normal protocol outcome.
reset_wire_buffer()
¶
Discard previously captured wire bytes.
send_body_bytes(data, *, byte_interval=0.0)
async
¶
Send body octets to the peer.
Same semantics as :meth:send_raw, kept separate for readability
at call sites that frame headers separately from the body.
send_chunk(data)
async
¶
Send one Transfer-Encoding: chunked chunk.
Caller must have already emitted Transfer-Encoding: chunked
via :meth:send_header_line and called :meth:end_headers.
Finish the chunked stream with :meth:end_chunked.
send_header_line(name, value)
async
¶
Emit one Name: Value\r\n header line with no dedup or
validation. Callers wanting a duplicate Content-Length or a
header value containing arbitrary bytes use this primitive
directly.
send_raw(data, *, byte_interval=0.0)
async
¶
Push arbitrary bytes onto the underlying socket.
When byte_interval > 0 the bytes are transmitted one at a time
with byte_interval seconds between writes — the primitive
slowloris-style stall the differential tests rely on. Each per-
byte write is followed by drain() (inherited from
:class:AsyncioWriter), so the bytes actually leave the socket
on schedule rather than accumulating in the asyncio send buffer.
send_request_line(method, target, *, version=b'HTTP/1.1')
async
¶
Emit METHOD<SP>TARGET<SP>HTTP/1.1\r\n with no validation.
Accepts arbitrary bytes for method/target/version so a
test can deliberately send b"BREW", lowercase versions, or
garbage tokens. No automatic Host or Content-Length injection —
the caller drives the wire bit by bit.
stream(method, path, *, headers=(), body=b'')
async
¶
Send a request and yield body chunks lazily.
Unlike request() this does not buffer the response body, so
gigabyte-sized responses do not need to fit in memory. Status and
headers are not exposed by this method; use request() if you
need them.
HTTP1RequestSender
¶
Writes an HTTP/1.1 request — request line, headers, body — to an AbstractWriter.
Adds Content-Length automatically for fixed-size byte bodies; switches
to Transfer-Encoding: chunked for AsyncIterable bodies. The
Host header MUST be present (RFC 7230 §5.4) — the helper raises
ProtocolError if it is not.
HTTP1ResponseRecipient
¶
Reads an HTTP/1.1 response from an AbstractReader.
Decodes both Content-Length-bound and Transfer-Encoding: chunked
bodies. Returns a ClientResponse; stream() returns an async
iterator of body chunks instead, so large responses don't have to fit
in memory.
HTTP2Client
¶
Async HTTP/2 client.
Use as an async context manager::
async with HTTP2Client('localhost', 8000) as c:
res = await c.request(HTTPMethod.GET, '/')
ssl=None (the default) selects plaintext h2c. Provide an
ssl.SSLContext with set_alpn_protocols(['h2']) for h2 over TLS.
Multiple request() calls share the same connection: each gets its own
odd, monotonically-increasing client-initiated stream ID
(RFC 7540 §5.1.1) and the responses are demultiplexed by the receive loop.
receive_raw_frame()
async
¶
Escape hatch: read one raw frame; bypasses the receive loop.
Only safe to call when the receive loop is not running (i.e. before
__aenter__ finishes or after the loop has been cancelled).
request(method, path, *, headers=(), body=b'')
async
¶
Send one request and await the matching response.
Adds :authority automatically from host:port. Header names
and values may be str or bytes; they are normalised to ASCII
str for HPACK encoding.
send_raw_frame(frame)
async
¶
Escape hatch: write a raw frame to the wire (negative-path tests).
HandshakeError
¶
Bases: ClientError
A WebSocket or HTTP/2 handshake failed.
ProtocolError
¶
Bases: ClientError
The client refused to send a request that violates the protocol.
ReadResponse
dataclass
¶
Read one HTTP/1.1 response from the connection.
timeout bounds the entire status-line + headers + body read.
On timeout the executor records the outcome on the
:class:ScenarioResult and does not raise — the caller decides
whether to treat that as a transport-fail or normal outcome.
ResponderFactory
¶
Looks up the Responder for an incoming frame type and instantiates it.
Scenario
dataclass
¶
A sequence of steps the executor walks against one connection.
from_bytes(raw)
classmethod
¶
Decode arbitrary bytes into a scenario.
Total function: every byte string yields a valid scenario, including the empty string (→ empty scenario). Designed so atheris's coverage-guided byte mutations always produce runnable input — the fuzzer never spends cycles on parser errors.
Encoding:
- The decoder walks
rawleft-to-right. At each position the next byte selects an opcode via% 4(every byte value is therefore a legal opcode tag). - Each opcode then consumes a small payload from the following bytes. If the payload is short (end of input), decoding stops cleanly and the partial scenario is returned.
Opcode layout::
byte % 4 == 0 → SEND
next 2 bytes (big-endian uint16) = length;
next ``length`` bytes = data;
next 1 byte (% len(_BYTE_INTERVAL_TABLE))
→ byte_interval.
byte % 4 == 1 → SLEEP
next 1 byte (% len(_SLEEP_TABLE)) → duration.
byte % 4 == 2 → READ
next 1 byte (% len(_TIMEOUT_TABLE)) → timeout.
byte % 4 == 3 → ABORT
no payload. Remaining bytes are discarded — an
Abort short-circuits execution anyway, so it's the
natural terminator.
Bounded payload sizes (uint16 length) keep individual scenarios well under 64 KiB, which is what we want for per-iteration fuzz throughput.
from_json(src)
classmethod
¶
Parse JSON Lines back to a :class:Scenario.
Skips blank lines so files that end with a trailing newline (the conventional git-friendly shape) parse cleanly.
to_json()
¶
Serialise to JSON Lines: one {"op": ..., ...} per line.
Bytes payloads are base64-encoded so the result round-trips
through stdout / git / json.loads without escape ambiguity.
Round-tripped by :meth:from_json.
well_formed(raw_request, *, response_timeout=5.0)
classmethod
¶
Wrap a complete raw HTTP/1.1 request as a one-shot scenario.
Equivalent to "send these bytes, then read one response".
Used by the legacy diff_*.txt corpus loader and by the
Hypothesis well_formed_scenario_strategy.
ScenarioResult
dataclass
¶
Outcome of one :meth:HTTP1Client.execute_scenario call.
Exactly one of response / exception / timed_out /
aborted is the meaningful field; the others are None /
False. The executor never raises, so callers (differential
test, fuzz harness) categorise on this object instead of writing
try/except boilerplate per scenario.
SendBytes
dataclass
¶
Push raw bytes onto the connection.
byte_interval > 0 transmits one byte at a time with that delay
between bytes — the primitive slowloris-style stall that lets
scenarios express trickled headers or trickled bodies without
dropping to a raw asyncio socket.
Sleep
dataclass
¶
Idle for duration seconds without sending or reading.
Useful for post-headers idle, mid-keep-alive idle, and pre-response stall scenarios where the server is expected to time out and close.
StreamReset
¶
WebSocketClient
¶
Async WebSocket client.
Use as an async context manager::
async with WebSocketClient('localhost', 8000) as c:
ws = await c.connect('/path', subprotocols=[b'chat'])
await ws.send_text('hello')
msg = await ws.receive()
await ws.close()
The transport is held open between connect() and __aexit__;
only one concurrent session per WebSocketClient is supported.
connect(path, *, subprotocols=())
async
¶
Run the HTTP/1.1 Upgrade: websocket handshake on this connection.
Returns a WebSocketSession once the server has confirmed the
upgrade with HTTP 101 and a valid Sec-WebSocket-Accept header.
Raises HandshakeError on any handshake-time failure.
WebSocketSession
¶
Frame-level WebSocket session over an established connection.
Always masks outgoing frames (RFC 6455 §5.1). Reads use
WebSocketRecipient(require_masked=False) because servers MUST NOT
mask their outgoing frames.
receive()
async
¶
Read one ASGI websocket.* event from the connection.
Returns one of
{'type': 'websocket.receive', 'text': str, 'bytes': None}{'type': 'websocket.receive', 'text': None, 'bytes': bytes}{'type': 'websocket.disconnect', 'code': int}
Server-initiated PING frames are auto-PONGed by the underlying
WebSocketRecipient (with masking, since this session is the
client). Server PONG frames are silently dropped.