scpi-replay
Record what a test program and a real instrument say to each other, keep it as a file (a "cassette"), and use it later without the hardware. Test software for benches needs instruments to run at all, and a simulator never behaves exactly like the real device. scpi-replay records the real conversation through a transparent TCP proxy on the instrument's raw SCPI socket, then replays it as a stand-in instrument, as a regression check against a real instrument or a simulator, or as a readable transcript.
$ scpi-replay record --target 192.168.1.50 -o dmm.scpi.jsonl # run the test software through the proxy
$ scpi-replay serve dmm.scpi.jsonl # later: the cassette answers instead
$ scpi-replay check dmm.scpi.jsonl --target 192.168.1.50 # replay the commands, compare the answers
$ scpi-replay show dmm.scpi.jsonl # print the transcript
Pure Python standard library, no runtime dependencies, Python 3.10 or newer, Linux, Windows and macOS.
Install
pip install scpi-replay
TOML rule files need tomllib, which comes with Python 3.11. On Python 3.10
install the extra: pip install "scpi-replay[toml]".
Quick start
All outputs below were produced with the files in examples/: a
session with a simulated multimeter (scpi-sim),
recorded through the proxy.
record: put the proxy between the test software and the instrument
$ scpi-replay record --target 192.168.1.50:5025 --listen 5030 -o dmm.scpi.jsonl
recording 192.168.1.50:5025 via 127.0.0.1:5030 -> dmm.scpi.jsonl
Point the test software at 127.0.0.1:5030 (for PyVISA:
TCPIP::127.0.0.1::5030::SOCKET) and run it. Bytes are forwarded unchanged in
both directions; the cassette is written message by message and flushed after
each one, so a crash loses nothing. The recorder runs until Ctrl+C, or with
--once until the first client disconnects (handy in scripts). Several clients
are fine, each gets its own connection number and its own connection to the
target. --listen defaults to 127.0.0.1:5025, or to a free port (printed) if
the target is local. The target port defaults to 5025; connecting to it times
out after 10 s. If the target refuses or does not answer, the client is
disconnected, and a warning is written to the cassette and printed on stderr
(scpi-replay: warning: ...). With --once the exit code is 1 if no
connection to the target was ever opened, else 0.
examples/drive_dmm.py is the PyVISA session that
produced examples/bench-dmm.scpi.jsonl.
show: read a cassette
$ scpi-replay show examples/bench-dmm.scpi.jsonl
-- conn 1 open
> *IDN?
< scpi-sim,DMM,SIM00001,0.5.0
> CONF:VOLT:DC 10
> READ?
< +4.99958428E+00
> READ?
< +4.99975954E+00
> READ?
< +4.99982347E+00
> SYST:ERR?
< 0,"No error"
-- conn 1 closed by client
> is sent to the instrument, < comes back. Binary blocks are shown as
<binary N bytes>; --times adds timestamps, --conn N selects one
connection. Colour is used on a terminal only, and NO_COLOR is respected.
check: replay the commands against an instrument and compare
$ scpi-replay check examples/bench-dmm.scpi.jsonl --target 127.0.0.1:5025 --rules examples/rules.toml --timing fast
IGNORED 001 *IDN?
PASS 003 READ?
PASS 004 READ?
PASS 005 READ?
PASS 006 SYST:ERR?
5 queries: 4 passed, 0 failed, 1 ignored
The same check against a DMM that reads 0.5 V too high (scpi-sim serve dmm --volts 5.5):
$ scpi-replay check examples/bench-dmm.scpi.jsonl --target 127.0.0.1:5025 --rules examples/rules.toml --timing fast
FAIL 003 READ? expected +4.99958428E+00, got +5.50023669E+00
FAIL 004 READ? expected +4.99975954E+00, got +5.50020550E+00
FAIL 005 READ? expected +4.99982347E+00, got +5.50034813E+00
IGNORED 001 *IDN?
PASS 006 SYST:ERR?
5 queries: 1 passed, 3 failed, 1 ignored
$ echo $?
1
check opens one connection to the target and sends every recorded command in
order (binary payloads byte for byte). After a query it reads one answer and
compares it with the recording. Failures are listed first.
--timing recorded(default) waits the recorded gap before each command, capped by--max-gap(default 10 s), because real instruments need settle time afterOUTP ONorINIT.--timing fastsends right away, good for simulators.--timeout(default 5 s) is the shortest wait for one answer. If the recorded answer took longer,checkwaits up to twice the recorded time plus 0.5 s, so a slow*OPC?is not cut off. No answer counts as FAIL. Answers that arrive late, or extra lines after an answer, are discarded before the next command is sent, never compared with the next query. If the connection is lost, the run stops and every remaining query counts as FAIL.--junit OUT.xmlwrites JUnit XML (one test case per query, named like003 READ?),--json OUT.jsonthe same data for scripts. test-protocol can read the JUnit file as it is.--conn Nchecks one recorded connection only.- Ctrl+C stops the run and prints the queries checked so far with
aborted: interrupted(the JUnit and JSON files are written too); the exit code is 1. Before the connection is made, Ctrl+C exits with 130. checksends every recorded command to the target, includingOUTP ONor*RST. Point it only at an instrument where that is safe, and note that a run that is stopped or loses the connection can leave an output switched on.- Exit codes: 0 all passed, 1 at least one FAIL (or interrupted), 2 usage error
(including a
--connthat is not in the cassette), cannot connect, or bad cassette.
serve: let the cassette be the instrument
$ scpi-replay serve examples/bench-dmm.scpi.jsonl --port 5025
serving examples/bench-dmm.scpi.jsonl on 127.0.0.1:5025 (lookup)
^C
served 5 queries, 0 unknown, 0 mismatches
The server listens on a raw socket (default 127.0.0.1:5025) and answers from
the cassette.
--mode lookup(default): each query is matched by its normalised command. Repeated queries get the recorded answers in recorded order, then the last one again. A query that is not in the cassette gets no answer, so the client times out like on a real instrument that does not know the command; it is logged on stderr as it happens (scpi-replay: unknown query: X?). Commands without a?are accepted silently.--mode strict: the client must send exactly the recorded commands in the recorded order. On the first mismatch the server logsscpi-replay: expected X, got Yand closes the connection, so the test fails fast instead of timing out.--conn Nserves one recorded connection (default: all of them, one after the other). Every new client starts from the beginning.--timing recordedwaits the recorded query-to-answer time before replying (capped by--max-delay, default 5 s). The defaultfastreplies at once.- On Ctrl+C the server prints a summary: queries served, unknown queries,
mismatches, and in strict mode the recorded exchanges never reached. The exit
code is 1 if there were unknown queries or mismatches, else 0. On Windows,
Ctrl+C prints the summary, but
taskkillends the process without it.
Cassette format
A cassette is a JSON Lines file (*.scpi.jsonl, UTF-8), one object per line,
easy to diff in git and to fix by hand. The first line is the header:
{"format": "scpi-replay/1", "recorded": "2026-10-30T19:28:53+01:00", "target": "127.0.0.1:44777", "tool": "scpi-replay 0.1.0", "note": ""}
Then one line per message or event, in the order they appeared on the wire:
{"t": 1.565, "conn": 1, "event": "open"}
{"t": 1.565, "conn": 1, "dir": "send", "data": "*IDN?"}
{"t": 1.565, "conn": 1, "dir": "recv", "data": "scpi-sim,DMM,SIM00001,0.5.0"}
{"t": 1.611, "conn": 1, "event": "close", "by": "client"}
| Field | Meaning |
|---|---|
t |
seconds since the recorder started |
conn |
connection number from 1; each client connection has its own |
dir |
send (client to instrument) or recv (instrument to client) |
data |
the message text without terminator |
term |
the terminator, only if it is not \n (for example "\r\n") |
b64 |
base64 instead of data for IEEE 488.2 binary blocks (#<n><len><bytes> and #0...), in both directions |
event |
open or close; a close has by: client, target, or recorder (the recorder was stopped with Ctrl+C while a client was connected) |
warning |
a parse problem; the bytes were forwarded anyway |
A \n outside a binary block always ends a message. A binary block starts
with # at the start of a message or after a space, tab or comma, so a # inside
a word (SER AB#15) is plain text. \r\n and \n both
count as terminators.
Exchanges. When loading, messages are grouped per send. A message is a
query if one of its ;-separated units has a ? in its header (outside quoted
strings). A query takes the next recv on the same connection as its answer.
A query with no answer before the next send or the close is stored as "no
answer", which is valid (real instruments do that on errors). A recv that does
not follow a query is "unsolicited": show prints it, serve and check ignore it.
Command normalisation (matching in serve and in rule patterns): trim,
uppercase the header, reduce every header node to its SCPI short form, and put
a single space between header and arguments. The arguments themselves are kept
as written, including spaces inside them. MEASure:VOLTage:DC? 10 and
meas:volt:dc? 10 both become MEAS:VOLT:DC? 10, but CONF:VOLT 10,1 and
CONF:VOLT 10, 1 stay different.
Rules
By default check splits answers at commas (outside quotes). Two fields are
equal if both are numbers with the same value (+1.000E+00 equals 1) or the
same text after trimming. Binary blocks must match byte for byte, and a
missing answer on one side must be missing on the other.
A TOML file adapts that per command. Rules are tried in order, the first match
wins. This is examples/rules.toml, used for the
outputs above:
[[rule]]
match = "*IDN?"
compare = "ignore" # serial number and firmware differ from unit to unit
[[rule]]
match = "READ?"
abs = 0.05 # readings are noisy: |actual - recorded| <= 0.05
match is a shell-style glob (Python fnmatch, itself normalised like a
command) on the normalised command, including its arguments. So ? in a
pattern is a wildcard for one character (it also matches the ? of a query),
and a pattern needs a trailing * to cover commands with arguments:
MEAS:VOLT:DC? does not match MEAS:VOLT:DC? 10, MEAS:VOLT:DC?* does.
Numeric tolerance: |actual - recorded| <= abs + rel * |recorded|, with abs
and rel both 0 by default, for example rel = 0.001 for 0.1 %.
compare |
meaning |
|---|---|
value (default) |
field by field; numbers with optional abs / rel tolerance |
ignore |
never fails, also if the answer is missing |
exact |
the answer text must be identical |
regex |
the actual answer must match pattern (the recording is only shown) |
length |
only the length must match, for binary or long answers |
pytest plugin
The plugin registers itself through the pytest11 entry point and provides the
scpi_cassette fixture:
def test_reads_voltage(scpi_cassette):
server = scpi_cassette("tests/cassettes/dmm.scpi.jsonl", mode="strict")
assert my_driver.read_voltage(server.resource) == pytest.approx(12.03, abs=0.01)
server.resourceisTCPIP::127.0.0.1::<port>::SOCKET;server.hostandserver.portare available too. Relative paths are relative to the pytest rootdir.- At teardown the test fails if the server saw unknown queries or mismatches,
or in strict mode if recorded exchanges were never reached (switch that off
with
require_all=False). pytest --scpi-record --scpi-target 192.168.1.50makes the fixture record instead of replay, writing the cassette to the given path. That refreshes cassettes when the hardware is at hand.target=in the call works too. Recording overwrites the file, so use one cassette per test: two tests that record to the same path leave only the second one's traffic.
A complete example is in examples/test_with_cassette.py.
Python API
from scpi_replay import Cassette, Recorder, ReplayServer, check
cassette = Cassette.load("dmm.scpi.jsonl")
with ReplayServer(cassette, mode="lookup") as server: # free port by default
run_my_test(server.resource)
assert not server.problems
with Recorder(("192.168.1.50", 5025), "new.scpi.jsonl") as rec:
run_my_test(rec.resource)
report = check(cassette, ("127.0.0.1", 5025), rules="rules.toml", timing="fast")
assert report.ok, report.text()
The servers run on a background thread with their own event loop. They are
silent by default; pass log=print (any callable taking a string) to
ReplayServer or Recorder to see problems and warnings as they happen.
check() and ReplayServer raise ValueError for a conn that is not in the
cassette.
Limits
- Raw-socket SCPI only (port 5025 style). No VXI-11 or HiSLIP proxying, no serial (RS-232 / USB-CDC) recording yet.
- Replay is a lookup, not a simulation: the cassette does not model instrument state. Use scpi-sim when state matters.
- Arguments are matched as written (only the space after the header is
normalised), so
OUTP ondoes not matchOUTP ON. Optional nodes such as[SOURce:]are not resolved. checkcannot tell a late answer from the next one: an answer that arrives after its wait has ended but after the next query was sent is taken as the answer to that query. Answers that are already there before a command is sent are discarded. Raise--timeoutfor slow instruments.- No SRQ, status-byte reads or device clear (these are not part of a raw socket).
- No cassette editing tools, no GUI.
- No redaction: a cassette contains everything that was sent, including serial numbers. Look before you publish one.
- The recorder forwards bytes first and parses next to it. A stream that cannot
be parsed is still forwarded; the cassette then holds what could be parsed
plus a
warning.
Development
pip install -e ".[dev]"
pytest -v
See CONTRIBUTING.md. Part of the steitec toolbox for test benches, with scpi-sim and test-protocol.
License
MIT, see LICENSE.
Metadata
Release files for scpi-replay 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| scpi_replay-0.1.0.tar.gz | 51.9 kB | With provenance | Details for scpi_replay-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| scpi_replay-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for scpi_replay-0.1.0-py3-none-any.whl |
Total release size: 83.1 kB
Release files / scpi_replay-0.1.0.tar.gz
| Download URL | scpi_replay-0.1.0.tar.gz |
|---|---|
| Size | 51.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
84c38bc29d31be074dba9ac32fd8decf75ba92e6eb942137a6854f8fbcf045e8
|
|
BLAKE2b-256 checksum How to use checksums |
0978ba120ea47090caabc19ef898c52793e3b042f515e490a7c6ac3c33ca63b4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.
Transparency logRelease files / scpi_replay-0.1.0-py3-none-any.whl
| Download URL | scpi_replay-0.1.0-py3-none-any.whl |
|---|---|
| Size | 31.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a14b71cfa8db1022845db4096a9eeb05b438949ad595aa677f7b4e61b6de11b3
|
|
BLAKE2b-256 checksum How to use checksums |
f200ccbf1a901dc1acaa24105df3f5c5480e74c407c89e78751834c825166abe
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.
Transparency logSecurity for scpi-replay 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for scpi_replay-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.