Python scripts¶
k9 run script.py imports the script once, reads its options dict (same keys
as a plan's options), runs setup(), then every VU iterates async def default(data)
(or a scenario's exec function) as a task on one asyncio event loop, then
teardown(data). See examples/script.py, which uses all of
the following against cargo run -p k9-testserver.
await k9.http.get(url, ...)(alsopost,put,patch,delete,head,options,request) sends the request on K9's Rust client: each VU has its own connections and cookie jar (emptied every iteration), and every request is recorded in k6'shttp_req_*metrics with k6's tags. Options:params,headers,data(bytes, text or a form),json,files(a multipart upload:{"doc": open(...)},{"doc": ("a.csv", data, "text/csv")}),compression(gzip,deflate,br,zstd, or"gzip, br"),tags,name(groups URLs with IDs into one entry) andtimeout. Run requests concurrently withasyncio.gather.- Like k6, requests send
Accept-Encoding: gzip, deflateunless they set their own, and compressed responses (gzip, deflate, br, zstd) are decoded:res.bodyis the decoded body, whiledata_receivedcounts what came over the wire. A body that isn't what itsContent-Encodingsays fails the request (error_codedecompress). - As in k6, a request that gets no response returns status 0 with
res.errorinstead of raising;res.raise_for_status()raisesk9.RequestErrorork9.StatusError. The response hasstatus,headers,body,text,json(),urlandtimings. async with k9.ws.connect(url, ...) as ws:opens a WebSocket (ws://orwss://, on a connection of its own;headers,tags,name,timeout).await ws.send(str | bytes),await ws.recv(timeout=...),async for message in ws,await ws.ping()(round trip in ms) andawait ws.close(code, reason). Messages are queued as they arrive, so a timed-outrecv()loses nothing; after the close,recv()raisesk9.ws.ConnectionClosed(with the peer'scodeandreason). A refused upgrade raisesk9.ws.WebSocketError. It's recorded in k6'sws_sessions,ws_connecting,ws_session_duration,ws_msgs_sent,ws_msgs_receivedandws_ping, plusdata_sent/data_received. In plaindefcode,k9.sync.ws.connect()gives the same with blocking calls (with … as sock,for message in sock).- gRPC (unary):
protos = k9.grpc.load("hello.proto", import_paths=[...])compiles.protofiles when the script loads (noprotocneeded), andclient = k9.grpc.Client("host:443", protos)(plaintext=Truefor h2c) can be shared by every VU: each VU calls on an HTTP/2 connection of its own.res = await client.invoke("pkg.Service/Method", {...}, metadata=..., timeout=...)takes and returns dicts (protobuf's JSON mapping);res.statusis ak9.grpc.StatusCode(k9.grpc.StatusOK…), withres.message,res.error,res.headersandres.trailers. A call that gets no answer has statusDEADLINE_EXCEEDEDorUNAVAILABLErather than raising. Recorded asgrpc_req_duration, tagged with the method andstatus.k9.sync.grpcblocks instead.k9.grpc.reflect(target, plaintext=...)gets the protos from the server's reflection service instead of files (at module level: it blocks). Seeexamples/grpc.py. - gRPC streams (client, server or bidirectional):
async with client.stream(method, metadata=..., timeout=...) as s:thenawait s.send({...}),s.close_send(), andawait s.recv()orasync for message in suntil the end (recv()givesNone); thens.status,s.error,s.headersands.trailersare set. Sending and receiving can run in different tasks.s.recv(timeout=1.5)raisesTimeoutErrorif nothing arrives in time; the stream stays open and the message isn't lost (nor whenasyncio.wait_forinterruptsrecv()). Recorded in k6'sgrpc_streams,grpc_streams_msgs_sentandgrpc_streams_msgs_received, and k9'sgrpc_stream_duration(open to end, or to cancel).k9.sync.grpcstreams block instead (with client.stream(...) as s,for message in s).k9.grpc.Client(..., metadata={...})sends that metadata with every call and stream (a call's own wins for the same key). k9.check(res, {"name": lambda r: ...})records thechecksmetric;with k9.group("name"):tags what runs inside it and recordsgroup_duration;await k9.sleep(seconds)pauses the VU.k9.metrics:Counter,Gauge,RateandTrend, declared at module level, appear in the summary and can have thresholds.- Hooks:
setup()returns JSON-serializable data and each VU gets its own copy;vu_init(data)runs once per VU before its first iteration (k9.vu.statekeeps per-VU values such as a login token);teardown(data)runs at the end, even after Ctrl-C;handle_summary(data)gets k6's summary data and returns what to write where ("stdout","stderr"or a file path;k9.text_summary()is the default). Hooks may bedeforasync def. k9.envis the environment plus-e KEY=VALUEflags plus the scenario'senv.k9.vu.id,k9.vu.scenarioandk9.vu.iterationdescribe the running VU.- Exceptions: an exception ends its iteration (which still counts, as in k6). The
first occurrence of each is printed with its traceback while the test runs; after the
summary, each is listed once with a count, e.g.
ERROR script raised 5×: ValueError: bad response (script.py:16 in default). K9's own exceptions derive fromk9.K9Error. - Exit codes follow k6: 107 when the script raises while loading or in a hook other
than
vu_init; 100/101 whensetup()/teardown()time out. - The package is fully typed: a type checker catches
k9.http.get(url, header=...). - Plain
deffunctions work too: each such VU runs on its own thread and uses the blocking twins ink9.sync(from k9.sync import http, sleep; same arguments, returns the response).check,groupand metrics are the same in both. A script can mixasync defanddefscenarios. When a sync VU is interrupted, its next k9 call raisesk9.Interrupted, and a request ork9.sync.sleepin progress returns at once. Sync hooks run on their own thread, so a slowvu_initdoesn't hold up other VUs. Using the async API in adeffunction (ork9.syncin anasync def) raises aContextErrorthat says which to use. Above 500 sync VUs, K9 warns: they share the GIL. Seeexamples/sync_script.py. k9 inspect script.pyprints the tier (async, orsyncwhen a scenario runs a plaindef), the hooks it found and the resolved options.--workers N(orauto, one per core) runs the test in N worker processes, one execution segment each, so a script gets N Python interpreters.setup(),teardown()andhandle_summary()run once, in the manager, andsetup()'s data is sent to every worker. You get one live status line and one summary; thresholds (andabortOnFail) judge all workers' samples together, and totals stay exact (shared-iterationsincluded). Ctrl-C stops every worker; a worker that crashes is reported (exit code 103).print()in scripts still shows, and--outoutputs get every worker's samples.
Measured with a release build on one machine, from one Python process: 1,000 VUs
requesting a 200 ms endpoint complete 99% of the theoretical iterations with p95
203.6 ms, at 54% CPU; 100 VUs against a fast endpoint send about 70,000 requests/s
(the same load as a declarative plan: 135,000/s). With --workers, pure-Python CPU work scales almost
linearly (3.75× with 4 workers, 6× with 8 on a 32-core machine). HTTP-bound async
scripts scale when each worker has enough VUs in flight: 8 workers send 153,000
requests/s at 256 VUs, and only 79,000 at 64 VUs. See
docs/benchmarks.md. Plain def VUs are slower, since
they hand the GIL back and forth: 100 of them send 10,900 requests/s, and 500 on a
200 ms endpoint reach 97% of the theoretical iterations (p95 204 ms). One Python
process is the limit; --workers spreads scripts across cores.