Transport-independent, asyncio-based SCPI commands and device helpers for Python 3.12, 3.13 and 3.14. Serial transport uses pyserial-asyncio; VISA is not required.
Install with pip install scpi or add the package with uv add scpi.
- Instantiate a transport. GPIB devices use
GPIBDeviceTransport. - Instantiate
SCPIDevicewith the transport, or with an explicitSCPIProtocolwhen you need to share a protocol. - Await the device methods from your application's event loop.
For blocking scripts and interactive use, wrap a device with AIOWrapper.
Each wrapper creates its own loop and closes it on quit(). When wrappers
share a transport, pass loop=controller.loop to the device wrappers; only
that controller owns the loop. Quit device wrappers before their controller.
Calling quit() repeatedly is safe. Use the asynchronous API inside an
already running event loop.
For example, connect to an HP 6632B power supply:
uv run --locked python examples/hp6632b_serial.py /dev/ttyUSB0
The example leaves an interactive dev object and registers shutdown with
atexit. See examples/ for TCP and Prologix GPIB examples. Physical
instrument access and serial-port permissions are required for those examples.
Serial factories and RS232Transport(serialdevice=port) remain synchronous.
They open or accept a pyserial port, then attach pyserial-asyncio to the running
event loop on the first command or read. Use one event loop per transport and
always await quit() (or call the blocking wrapper's quit()).
Ordinary serial commands and replies use ASCII and CRLF; Prologix uses LF.
Replies are buffered, including replies received before get_response().
Response waits are cancellable; a disconnect fails pending reads. Explicit
message callbacks remain supported, and unsolicited callbacks receive lines
when no response read is pending. Serial BREAK uses an asynchronous delay.
Prologix initialization runs automatically on first use. To reset the controller
explicitly, use await transport.initialize_controller(); this method is now
asynchronous. Serial read/write timeouts become zero for nonblocking I/O;
SCPI command timeouts and asynchronous write flow control bound waits instead.
On POSIX, pyserial-asyncio requires a serial file descriptor. In-memory
loop:// ports do not provide one; the tests use pseudo-terminals instead.
See the pyserial-asyncio documentation.
The project uses uv, Hatchling, Ruff, strict Pyrefly and prek:
uv sync --locked uv run --locked prek install uv run --locked prek run --all-files uv run --locked pytest uv run --locked pyrefly check uv run --locked bandit -r src --skip B101
Run the supported Python matrix with uv run --locked tox. Tox needs Python
3.12, 3.13 and 3.14 available; uv python install 3.12 3.13 3.14 can install
them. Tests use fake devices and POSIX pseudo-terminals and need no hardware.
Serial integration tests are skipped on Windows.
The Bandit B101 exclusion permits existing internal assertions.
From a clean working tree:
uv run --locked bump-my-version bump patch uv lock uv run --locked prek run --all-files uv run --locked pytest uv build
The bump updates package metadata, the package version and its test. Commit
those files and uv.lock together. It does not automatically commit, tag,
publish or push. Builds retain LICENSE and py.typed and exclude the
local, untracked HANDOFF.md from source distributions.
Pull requests run Python and container checks without release permissions.
After a merge or direct push to master, a separate workflow runs the same
checks, then creates a Git tag and GitHub release matching project.version
(for example, 2.6.0), with generated release notes. Existing tags and releases
are left unchanged, so bump the version before merging a new release. A missing
release is created even if its tag already exists. The release job uses
GITHUB_TOKEN with contents: write; it does not publish packages.
Dockerfile uses Debian Trixie; Dockerfile_alpine uses Alpine 3.24.
Both use Python 3.14 and expose test, tox, devel_shell and
production targets. Tox checks Python 3.12 through 3.14. Production installs
only runtime dependencies, non-editably, and prints the package version by
default. Pass a command to run your own script.
Build and run all targets locally:
./run_tests.sh CONTAINER_ENGINE=podman ./run_tests.sh
Filter a run or use an optional wheelhouse:
CONTAINER_ENGINE=podman VARIANTS=alpine TARGETS=tox ./run_tests.sh WHEELHOUSE_URL=http://host.docker.internal:3141/debian/ VARIANTS=debian ./run_tests.sh
Sources are copied into images; no host virtualenv, SSH agent or source bind mount is required. The wheelhouse is optional. To build a production image:
docker build --target production -t scpi:local . docker run --rm scpi:local
Use podman in place of docker if preferred. Access to physical serial
instruments must be configured separately for your container engine.
GitHub Actions runs checks on Python 3.12, 3.13 and 3.14, and builds/runs all four container targets on both distributions. Container jobs use Docker.
Consider configurable carrier-detect and CTS checks for RS232 devices that support those signals.