spvirit — EPICS PVAccess for Python, powered by Rust
spvirit is a pure-Rust implementation of the EPICS PVAccess protocol with
first-class Python bindings. It lets you build soft IOCs, simulators, gateways,
and clients entirely from Python — no EPICS base installation required — while
the protocol machinery, server, and network stack run in Rust on a Tokio
runtime.
import spvirit
temp = spvirit.ai("SIM:TEMP", 21.5, units="C", prec=2)
setpoint = spvirit.ao("SIM:TEMP:SP", 20.0, units="C", drive_limits=(0.0, 100.0))
@setpoint.on_put
def validate(pv, value):
print(f"setpoint changed to {value}")
server = spvirit.Server(pvs=[temp, setpoint])
server.start()
temp.set(21.7) # published to all monitoring clients
print(setpoint.get()) # read the live value
This document is the complete guide to the Python API. For the Rust crates,
protocol internals, and the CLI tools (spget, spput, spmonitor,
spserver), see the repository README.
Table of contents
- Installation
- Core concepts
- IOC-style record PVs vs raw NT PVs
- Building servers with typed PV handles
- Loading EPICS .db files
- The classic builder API
- Runtime store access
- Dynamic sources
- Lifecycle hooks and events
- Normative Type classes
- Client API
- Low-level API: spvirit.lowlevel
- Wire codec: spvirit.codec
- Threading and async model
- Errors and exceptions
- Examples
- Building from source
Installation
pip install spvirit
Wheels are published for Linux (x86_64, aarch64), Windows (x86_64), and macOS (x86_64, arm64) for Python 3.9+ (one abi3 wheel per platform). No EPICS base, no compiler needed.
This also installs the spvirit-tools package, which puts the thirteen
command-line tools (spget, spput, spmonitor, splist, spinfo,
spexplore, spsearch, spsine, spserver, sptable, spdodeca,
spget_compare and spgateway) on your PATH inside the same environment. They are native
binaries, not Python wrappers, and each takes --help.
To build from source instead, see Building from source.
Core concepts
- PV handles (
spvirit.Pv) are typed references to process variables. You create them with factory functions (spvirit.ai(...),spvirit.ao(...), …), hand them to aServer, and thenset()/get()them freely from Python while clients read, write, and monitor them over the network. - The server owns a record store that speaks the PVAccess protocol:
search/beacon UDP, TCP circuits, monitors with
MDEL/ADELdeadbands, alarm computation, and QSRV-style field access (PV.RTYP,PV.DESC,PV.EGU, …) all work out of the box, including with the EPICS Archiver Appliance and standard tools (pvget,pvput,pvmonitor, Phoebus). - Sources let you claim PV names dynamically from Python objects instead of a static record store — for gateways, bridges, or fully virtual PVs.
- The client side offers a high-level
Client(get/put/monitor/info) and a low-levelChannel(persistent connection, raw frame access) plus discovery and codec utilities. - Everything blocking releases the GIL and runs on a shared Tokio runtime;
async variants (
set_async,get_async,connect_async, ...) integrate withasyncio. See Threading and async model.
IOC-style record PVs vs raw NT PVs
Everything on the wire is a Normative Type (usually NTScalar), but the API works at two distinct levels — know which one you are on:
IOC-style records are what the typed handles, the classic builder, and
.db loading create. The server behaves like an EPICS record: units,
prec, desc, and limits become record fields visible QSRV-style
(PV.EGU, PV.DESC, …); MDEL/ADEL deadbands throttle monitors; alarm
severity is computed from the alarm limits (or set with set_alarm); every
post is timestamped for you. You work with plain Python values —
pv.set(21.5), store.set_value(...).
Raw NT PVs are full Normative Type payloads you construct and move
yourself: NtScalar and friends written with
store.put_nt(...), published with notifier.notify(...), or served from a
dynamic source's get/put. You control — and must
supply — the alarm, timestamp, display, and control metadata on each
payload; no deadbands or alarm computation are applied for you. (NtScalar
payloads are always stamped by the server, since the constructor has no
timestamp argument. For array payloads, store.put_nt stamps a zeroed
timestamp, but one served from a source's get or notifier.notify goes out
as-is, so a zero there reads as 1970.)
| IOC-style records | Raw NT payloads | |
|---|---|---|
| Created via | spvirit.ai(...) …, ServerBuilder, db_file= |
NtScalar(...) …, PvInfo + source |
| Read/write | plain values (set/get, set_value) |
whole payloads (put_nt/get_nt, notify) |
| Alarms, timestamps, deadbands | managed by the server | yours, per update |
| Best for | soft IOCs, simulators, record-like PVs | gateways, tables, images, per-update metadata |
The levels mix freely: store.get_nt(name) returns the full NT payload of
an IOC-style record, and store.put_nt writes one — useful when a mostly
record-like PV occasionally needs explicit metadata.
Building servers with typed PV handles
This is the recommended API. Each factory returns a Pv handle; the handle is
pending until a Server is built from it, and live afterwards.
Creating PVs
Scalar records (each is served as an NTScalar with the given value type):
| Factory | Python type | Wire NT type | Writable by clients | EPICS analogue |
|---|---|---|---|---|
spvirit.ai(name, initial, **opts) |
float |
NTScalar double |
no | analog input |
spvirit.ao(name, initial, **opts) |
float |
NTScalar double |
yes | analog output |
spvirit.bi(name, initial, **opts) |
bool |
NTScalar boolean |
no | binary input |
spvirit.bo(name, initial, **opts) |
bool |
NTScalar boolean |
yes | binary output |
spvirit.longin(name, initial, **opts) |
int |
NTScalar int (32-bit) |
no | long input |
spvirit.longout(name, initial, **opts) |
int |
NTScalar int (32-bit) |
yes | long output |
spvirit.string_in(name, initial, **opts) |
str |
NTScalar string |
no | string input |
spvirit.string_out(name, initial, **opts) |
str |
NTScalar string |
yes | string output |
spvirit.scalar(name, initial, *, type, writable=False, **opts) |
per type | NTScalar <type> |
writable= flag |
— |
"Read-only over the wire" means network clients cannot PUT the value; your
Python code can always set() it. For the remaining NTScalar wire types
(long, unsigned, float) use spvirit.scalar(...), which covers all
twelve.
NT scalar type coverage
PVAccess defines twelve NTScalar value types: boolean, byte, short,
int, long, their unsigned variants (ubyte, ushort, uint, ulong),
float, double, and string. All twelve are creatable:
- Scalars —
spvirit.scalar(name, initial, *, type, writable=False, **opts)selects the wire type explicitly by name (or alias, e.g."i32"/"u16"/"f64");writable=Trueserves the output flavor,False(default) the input flavor. - Arrays —
waveform/aai/aao(name, data, *, type=None)take the sametype=for the element type; see below for the inference behavior whentype=is omitted.
counter = spvirit.scalar("SIM:COUNT", 0, type="long", writable=True)
gain = spvirit.scalar("SIM:GAIN", 1, type="ushort")
level = spvirit.scalar("SIM:LEVEL", 3.3, type="float", units="V")
The type string follows the PvInfo convention used throughout the API:
"boolean"|"bool", "byte"|"int8"|"i8", "short", "int", "long",
"ubyte", "ushort", "uint", "ulong", "float"|"f32",
"double"|"f64", "string"|"str". An unrecognized type string raises
ValueError.
Coercion is strict. Converting a Python initial value (or a later set()
through a typed handle) to the target wire type follows these rules:
- Out-of-range values (e.g.
300into"byte",-1into"ushort") raiseOverflowError. - Wrong-kind values (
strinto an int/float type, a fractionalfloatlike1.5into an int type, aboolinto a non-boolean type) raiseTypeError. - Widening is always allowed:
int→float/double, and an integral float (2.0) → any int type. - Narrowing
double→floatis allowed and may lose precision (lossy f64→f32 rounding), same as an EPICSfloatfield.
server.pv(name) — which can attach to any served record, including
.db-loaded and classic-builder ones — no longer raises KeyError for
long/unsigned records. It maps wire types onto handles as follows:
| Wire NT scalar type | Handle kind | Notes |
|---|---|---|
double, float |
float |
float (f32) is widened to Python float |
boolean |
bool |
|
byte, short, int |
int |
narrower ints are widened to 32-bit |
string |
str |
|
| NTEnum | int |
the value is the choice index |
| NTScalarArray (any element type) | array | see element notes below |
long, ubyte, ushort, uint, ulong |
dynamically typed handle | repr(pv) shows the wire type, e.g. <spvirit.Pv 'SIM:COUNT' (long)> |
A dynamically typed handle's set()/get() still uses plain Python int
values, coerced with the same strict rules as above (an out-of-range write
raises OverflowError).
Widened int handles are range-checked. A byte/short/int
record maps onto the plain Python int handle kind above. Writes through a
widened byte/short handle are range-checked: a value the record's type
cannot represent (e.g. 300 written to a byte record) is refused, the
record is left unchanged, and the write raises TypeError.
Array element types: waveform/aai/aao infer the element type from the
data when type= is omitted — bytes becomes an unsigned-byte (ubyte[])
array and reads back as bytes; lists become boolean[]/long[]/
double[]/string[] from the first element (int lists are stored as 64-bit
elements) and read back as lists. Mixed-type lists raise TypeError. Passing
type= overrides inference and coerces every element strictly (same
OverflowError/TypeError rules as scalars).
Enum records (served as NTEnum, value is the choice index):
mode = spvirit.mbbi("SIM:MODE", ["Off", "Standby", "Running"], 0)
cmd = spvirit.mbbo("SIM:CMD", ["Stop", "Start", "Reset"], 0, desc="Command")
mbbi is deliberately read-only over the wire in spvirit (a client PUT is
refused; EPICS Base allows VAL writes to input records); update it from
Python with set(). mbbo is writable: a client PUT selects the choice by
index. A wire PUT with an out-of-range index is refused with an error; a
set() with an out-of-range index leaves the value unchanged and raises
nothing. Only the desc option applies to enums.
Array records (served as NTScalarArray):
wf = spvirit.waveform("SIM:WF", [0.0] * 1024) # writable
raw = spvirit.aai("SIM:RAW", bytes(512)) # read-only, U8 array
table = spvirit.aao("SIM:TBL", [1, 2, 3]) # writable
counts = spvirit.waveform("SIM:CNT", [0] * 1024, type="ushort") # explicit element type
data may be a list of bool/int/float/str (element type inferred from
the first element; an empty list defaults to double[] only when type= is
omitted) or bytes (stored as an unsigned-byte array, returned to Python as
bytes). Pass type= to pick any of the twelve wire element types
explicitly, overriding inference.
Type-inferred creation — spvirit.pv(name, initial, **opts) picks the record
type from the initial value:
| Initial value | Result |
|---|---|
bool |
bo (checked before int — True is an int in Python) |
int |
longout |
list or bytes |
waveform |
float |
ao |
str |
string_out |
| anything else | TypeError |
Inference always produces the writable flavor; use the explicit factories
when you need read-only records. Pass type= to spvirit.pv(...) to override
inference and pick the wire type explicitly: double/boolean/int/
string map onto the same native handle kinds as above, any other type
(long, unsigned, float) produces a dynamically typed handle, and a
list/bytes initial value with type= produces a typed waveform.
Common options
All scalar factories (and spvirit.pv) accept these keyword-only options:
pressure = spvirit.ai(
"SIM:PRESSURE", 101.3,
units="kPa", # engineering units (EGU)
prec=1, # display precision (PREC)
desc="Chamber pressure", # description (DESC)
adel=0.5, # archive deadband (ADEL)
mdel=0.1, # monitor deadband (MDEL)
drive_limits=(0.0, 200.0), # control limits (DRVL, DRVH)
alarm_limits=(10.0, 20.0, 150.0, 180.0), # (LOLO, LOW, HIGH, HIHI)
)
mdelsuppresses monitor updates smaller than the deadband;adeldoes the same for the archive-tuned monitor channel (what the Archiver Appliance subscribes to).alarm_limitscombined with the server's alarm computation drives MINOR/MAJOR alarm severity automatically as the value crosses the limits.- Field values are visible to clients QSRV-style:
pvget SIM:PRESSURE.EGU,SIM:PRESSURE.DESC,SIM:PRESSURE.RTYP, etc.
Reading and writing: set / get / set_async / get_async
temp.set(21.7) # blocking; full posting pipeline (monitors, deadbands, alarms)
value = temp.get() # blocking; returns float/bool/int/str/list per PV type
# asyncio variants
await temp.set_async(21.8)
value = await temp.get_async()
- All four release the GIL; blocking calls are safe from any Python thread.
- Writing the wrong Python type raises
TypeError. - On an array handle,
set()coerces the given list/bytes to the record's current element type — a plain Pythonintlist written to aushort[]record staysushort[]on the wire, and an out-of-range element raisesOverflowError.set()never changes an array's element type. - Calling
set/geton a handle that has not been given to aServeryet raisesRuntimeError("unbound"). pv.nameis the PV name;repr(pv)shows the name and value kind, e.g.<spvirit.Pv 'SIM:TEMP' (float)>.set()writes authoritatively: it is not subject to the PV'son_putvalidator, which guards client (wire) writes only — the same split as p4p'spost()vs its put handler.
Reacting to client writes: on_put
on_put attaches a validator/handler that runs before a client PUT is
applied. Use it as a decorator or a plain method call:
sp = spvirit.ao("SIM:SP", 20.0)
@sp.on_put
def check(pv, value):
if value < 0:
return False # reject: client's put fails on the wire
print(f"{pv.name} <- {value}")
- The callback receives
(pv, value)— the handle itself and the incoming value, already converted to the handle's Python type. - Returning
Falseor raising any exception rejects the PUT: the value is not applied and the writing client receives an error. Any other return value (includingNone) accepts it. - Callbacks may freely call
set()/get()on other PVs (re-entrancy is safe), e.g. to update a readback when a setpoint changes. - Not supported on array PVs (
TypeError). on_putreturns the callback unchanged, so the decorated function remains usable.
Periodic updates: scan
scan registers a function called at a fixed period; its return value is
posted to the PV. Two forms:
noise = spvirit.ai("SIM:NOISE", 0.0)
@noise.scan(period=0.1) # decorator form
def tick(pv):
return random.gauss(0.0, 1.0)
heartbeat = spvirit.longin("SIM:HB", 0)
count = itertools.count()
heartbeat.scan(1.0, lambda pv: next(count)) # direct form: scan(period, fn)
- The callback receives the handle and must return the new value.
- If it returns
None, returns a non-convertible value, or raises, the scan re-posts the last value this scan produced (before the first successful tick, the type default:0.0,False,0, or""). It does not read the PV's live value. - Scans must be attached before constructing the
Server; attaching one to an already-bound handle is a no-op (a warning is logged). - Not supported on array PVs (
TypeError).
Computed PVs: calc
spvirit.calc creates a read-only float PV recomputed whenever any of its
input PVs changes:
a = spvirit.ao("SIM:A", 1.0)
b = spvirit.ao("SIM:B", 2.0)
total = spvirit.calc("SIM:SUM", [a, b], lambda vals: vals[0] + vals[1])
- Inputs must all be float PVs (
ai/ao); anything else raisesTypeError. - The callback receives the current input values as
list[float]and returns afloat. Exceptions or non-float returns are logged and treated as0.0. - Like scans, calc PVs must be created before the
Serveris built.
Alarms: set_alarm
Set a record's alarm state explicitly, independent of its value:
temp.set_alarm(2, 3, "sensor unplugged") # severity=MAJOR, status=3 (spget shows HIHI)
temp.set_alarm(0, 0) # clear (message defaults to "")
Severity follows the EPICS convention: 0 = NO_ALARM, 1 = MINOR, 2 = MAJOR, 3 = INVALID. The change is published to monitoring clients immediately. Available on scalar and array handles.
The Server class
server = spvirit.Server(
pvs=[temp, setpoint, wf], # typed handles (and/or use db_file/db_string)
db_file="records.db", # optional: load an EPICS .db file
db_string="...", # optional: inline .db content
sources=[("gw", 10, MySource())], # optional: dynamic sources (label, order, obj)
port=5075, # TCP port (default 5075)
udp_port=5076, # UDP search/beacon port (default 5076)
listen_ip="0.0.0.0", # bind address
advertise_ip="192.168.1.10", # address advertised in beacons/search replies
compute_alarms=True, # derive severity from alarm_limits
beacon_period=15, # beacon interval, whole seconds
)
All arguments are keyword-only and optional. Then either:
server.start()— serve on a background thread and return immediately (the usual choice), orserver.run()— serve on the current thread, blocking forever, orserver.start_background()— likestart()but returns aStorehandle.
Other methods:
server.pv(name) -> Pv— mint a typed handle to any served record, including ones loaded from a.dbfile or added via the classic builder. The handle's type is inferred from the record (floats → float, enums → int index, arrays → array). Unknown names raiseKeyError, and so do records with no handle representation (NTTable, NTNDArray, generic structures — use theStorefor those).Server.builder()— static shorthand forspvirit.ServerBuilder().server.store() -> Store— runtime get/set access (see below).server.notifier() -> Notifier— publish monitor updates for source-claimed PVs.server.add_source(label, order, source)— register a dynamic source after construction.
Handles work identically before and after start(): set() on a pending
handle raises RuntimeError; once the server is constructed the handle is
bound and live.
Generating many PVs programmatically
Handles are plain Python objects — build them in loops and comprehensions:
import spvirit
channels = {
f"BL:DET:CH{i:03d}": spvirit.ai(f"BL:DET:CH{i:03d}", 0.0, units="counts")
for i in range(100)
}
setpoints = []
for i in range(16):
sp = spvirit.ao(f"BL:MOT:M{i}:SP", 0.0, drive_limits=(-180.0, 180.0))
@sp.on_put
def moved(pv, value, i=i): # bind i per-PV
print(f"motor {i} -> {value}")
setpoints.append(sp)
server = spvirit.Server(pvs=[*channels.values(), *setpoints])
server.start()
channels["BL:DET:CH042"].set(1234.0) # keep the dict for later access
Tier 3 — an IOC you can drive from Python
import spvirit
sp = spvirit.ioc.ao("RIG:SP", OUT="RIG:RBV.VAL", FLNK="RIG:RBV")
rbv = spvirit.ioc.ai("RIG:RBV", INP="RIG:SP PP", EGU="C",
HIHI=100, HHSV="MAJOR", MDEL=0.1)
ioc = spvirit.Ioc(records=[sp, rbv])
sp.set(95) # writes VAL, processes the chain, notifies monitors
print(rbv.get()) # 95
print(rbv["EGU"]) # "C" — field reads are read-only in A3
ioc.run() # or: spvirit.Server(ioc=ioc, pvs=[...]).run()
Field names are verbatim EPICS. EGU=, HHSV=, MDEL= — not tier 2's
units= or drive_limits=. A tier-3 field has to round-trip to .db text
and surface as a .FIELD PV, so rec["EGU"] and EGU= must be the same
token. Passing a tier 2 spelling raises and names the EPICS one.
spvirit.Server(db_file=…) is not this. It keeps serving a .db as inert
values with SCAN and FLNK parsed and ignored. Use spvirit.Ioc(db_file=…)
for a processing IOC; the separate name is deliberate.
No on_put on tier 3. It would run Python inside process() while a lock
set is held. rec.on_put raises and points at tier 2.
Loading EPICS .db files
Existing StreamDevice/EPICS-style .db files load directly:
server = spvirit.Server(db_file="ioc.db")
# or
server = spvirit.Server(db_string="""
record(ao, "DEMO:VOLTAGE") {
field(DESC, "Supply voltage")
field(EGU, "V")
field(VAL, "5.0")
}
""")
v = server.pv("DEMO:VOLTAGE") # typed handle onto a .db-loaded record
v.set(5.2)
Supported record types are ai, ao, bi, bo, stringin, stringout,
waveform, aai, aao and subarray, with common fields (VAL, DESC,
EGU, PREC, HIHI/HIGH/LOW/LOLO, DRVH/DRVL, ADEL, MDEL, …).
mbbi/mbbo (and longin/longout) are not yet built by the .db loader:
loading one fails with record '…' of type 'mbbi' is not yet supported by the
.db loader (create enums with spvirit.mbbi/mbbo instead).
The classic builder API
The fluent ServerBuilder predates the typed handles and remains fully
supported — it also exposes a few record shapes the handle API does not yet
(tables, ND arrays, sub-arrays, generic structures):
import spvirit
server = (
spvirit.ServerBuilder()
.ai("DEMO:TEMP", 21.5)
.ao("DEMO:SP", 20.0)
.mbbo("DEMO:MODE", ["Off", "On", "Auto"], 0)
.waveform("DEMO:WF", [0.0] * 100)
.waveform("DEMO:CNT", [0] * 100, type="ushort")
.sub_array("DEMO:WF10", [0.0] * 100, indx=5, nelm=10)
.nt_table("DEMO:TBL", {"name": ["a", "b"], "value": [1.0, 2.0]},
types={"value": "float"})
.nt_ndarray("DEMO:IMG", bytes(64 * 48), dims=[(64, 0), (48, 0)])
.generic("DEMO:CFG", "my:struct:1.0", {"gain": 2.5, "taps": [1, 2, 3]},
types={"taps": "short[]"})
.db_file("extra.db")
.on_put("DEMO:SP", lambda name, value: print(name, "<-", value))
.scan("DEMO:TEMP", 1.0, lambda name: read_sensor())
.port(5075)
.udp_port(5076)
.listen_ip("0.0.0.0")
.advertise_ip("192.168.1.10")
.compute_alarms(True)
.beacon_period(15)
.add_source("gateway", 10, MySource())
.build()
)
server.start()
The full method set: record definitions ai, ao, bi, bo, string_in,
string_out, mbbi, mbbo, waveform, aai, aao, sub_array,
nt_table, nt_ndarray, generic (no longin/longout: use the handle
factories spvirit.longin/longout, or tier 3's spvirit.ioc.longin/
longout); loading db_file, db_string; callbacks on_put, scan;
lifecycle on_start, on_event (see Lifecycle hooks and events);
configuration port, udp_port, listen_ip,
advertise_ip, compute_alarms, beacon_period (whole seconds),
add_source; and build().
Differences from the handle API:
on_put(name, callback)is string-keyed and receives(pv_name, value); exceptions are logged, not used to reject the put.scan(name, period, callback)receives the PV name and returns the new value; errors post0.0.build()returns the sameServerclass described above, soserver.pv(name)works to obtain typed handles onto builder-defined records afterwards.- A builder is single-use: any method after
build()raisesRuntimeError. - The builder's record methods take no metadata kwargs (
units=,prec=, …); set fields via a.dbfile or use the handle factories. Value types are selectable, though:waveform/aai/aao/sub_array/nt_ndarraytaketype=for the element type, andnt_table/generictaketypes=— a dict mapping column/field name to a type string (e.g.{"taps": "short[]"}for an array-typed generic field). Omittingtype=/types=falls back to the same inference as the handle factories.
Runtime store access
Store (from server.store() or start_background()) provides direct,
name-keyed access to the record store — useful for generic tooling where
typed handles are inconvenient:
store = server.store()
store.pv_names() # -> list[str], all served PVs
store.get_value("DEMO:TEMP") # -> scalar value or None
store.set_value("DEMO:TEMP", 22.0) # -> True if the value changed (False if unchanged or the PV is not served)
store.set_array_value("DEMO:WF", [1.0, 2.0, 3.0])
store.get_nt("DEMO:TEMP") # -> full NT payload (NtScalar, ...)
store.put_nt("DEMO:TEMP", nt) # write a full NT payload
get_nt/put_nt round-trip complete Normative Type payloads including alarm,
timestamp, display, and control substructures — see
Normative Type classes.
set_value and set_array_value coerce the incoming value strictly to the
record's existing wire type (the same OverflowError/TypeError rules as
elsewhere) and never retype a record — there is no way to change a scalar or
scalar-array record's wire type after creation through the store. put_nt
follows the same never-retype rule for NtScalar/NtScalarArray payloads;
for NtTable/NtNdArray payloads, put_nt replaces the record's stored
payload wholesale — columns, element types, and all — since the whole
structure comes from the payload you supply. Writing to a name that isn't
served returns False (and get_value returns None) rather than raising;
a write that leaves the value unchanged also returns False.
Dynamic sources
Sources claim PV names at runtime instead of serving a fixed record store —
the building block for gateways, protocol bridges, and virtual PV namespaces.
A source is any Python object implementing this duck-typed protocol
(each method may be def or async def):
import time
import spvirit
class SensorSource:
def __init__(self):
self.notifier = None
def claim(self, name):
"""Return PvInfo (or a dict) to claim `name`, None to decline."""
if name.startswith("SENSOR:"):
return spvirit.PvInfo.nt_scalar("double", writable=True)
return None
def get(self, name):
"""Return the current value as an NT payload."""
return spvirit.NtScalar(read_hardware(name), units="C")
def put(self, name, value):
"""Apply a client write. Raise to reject.
Optionally return NT payload(s) to publish as monitor updates."""
write_hardware(name, value)
return spvirit.NtScalar(value)
def names(self): # optional: PV listing support
return ["SENSOR:T1", "SENSOR:T2"]
def rpc(self, name, args): # optional: NTURI RPC support
return spvirit.NtScalar(float(args["x"]) * 2)
def on_start(self, notifier): # optional: called at server start, not at construction
self.notifier = notifier # keep it to push updates later
def fields(self, name): # optional: `.FIELD` access support
if not name.startswith("SENSOR:"):
return None
return {"DESC": "a sensor reading", "EGU": "C"}
server = spvirit.Server(sources=[("sensors", 10, SensorSource())])
server.start()
Key points:
claim(name)is called on client search; returnspvirit.PvInfo, a dict{"struct_id": ..., "fields": {...}, "writable": bool}, orNone. Convenience constructors:PvInfo.nt_scalar("double", writable=True),PvInfo.nt_scalar_array("double")(pass the element type), or the fullPvInfo(struct_id, fields, writable=False)wherefieldsmaps field names to type strings ("double","int","string","boolean","double[]","any", …). APvInfoexposes read-only.writableand.struct_idproperties.putreturn values become monitor updates: return one NT payload, a dict{pv_name: payload}, or a list of(pv_name, payload)tuples to fan updates out to related PVs; returnNonefor no propagation. Raising an exception rejects the client's PUT.- Push updates at any time via the
Notifier(fromon_startorserver.notifier()):notifier.notify(pv_name, nt_payload). It is safe to call from any thread, including from inside another source callback. - The
ordernumber decides precedence when several sources could claim a name — lower is tried first; the built-in record store is order 0. async defsource methods run on a dedicated background asyncio event loop (thread namespvirit-asyncio) — do not callasyncio.runyourself inside source methods.
fields(self, name) -> dict | None (optional)
Return a mapping of EPICS field name to value for the record name, or
None if this source does not own it. Defining it makes <name>.<FIELD>
resolvable for this source's records, matching what the builtin store and
the processing engine already serve. Fields you omit read as their
dbCommon defaults, so a source usually only lists DESC, EGU, RTYP
and whatever else it genuinely knows.
def fields(self, name):
if name != "PY:A":
return None
return {"DESC": "a python record", "EGU": "mm"}
Field PVs are read-only.
The .FIELD tier is derived automatically on every registration path —
ServerBuilder.add_source, Server(sources=[...]), and post-build()
Server.add_source alike. It is registered as a separate source labelled
{label}-fields at order + 10, so a source of your own registered at that
exact order shares the slot; ties resolve by registration order.
Lifecycle hooks and events
@builder.on_start
Registered on the builder, before build() — there is no server.on_start.
A hook runs once, at server start: after the store is built, before scan
tasks spawn, and before the server accepts connections. No client can
observe a pre-initialisation value.
b = spvirit.ServerBuilder().ao("SETPOINT", 0.0).port(0).udp_port(0)
@b.on_start
def initialise(store):
store.set_value("SETPOINT", 22.5)
server = b.build()
server.start_background()
Hooks may be def or async def (an async def is awaited on the
spvirit-asyncio loop) and run in registration order. A dynamic
source's on_start(notifier) shares that same ordered
list — builder hooks and source hooks interleave in true registration
order, whichever add_source/@builder.on_start call came first. A
source's order value governs which source claims a PV name; it has no
effect on this startup sequence.
A hook that raises (or panics) aborts startup — the server does not serve, and the error names the hook. This is deliberate: startup is a barrier under your control, so failing loudly and early is safe. A handler firing at 3am from a live event is a different story — see below.
Migration note. Before this feature, a Python source's
on_startfired duringbuild(). It now fires at server start (start()/start_background()/run()), alongside@builder.on_starthooks. Code that relied on a source'son_startrunning atbuild()time will now see it run later.
@builder.on_event and post_event
A named trigger that fans out across the whole server. Handlers are also registered on the builder, not the running server.
b = spvirit.ServerBuilder().ao("SHUTTER:COUNT", 0.0).port(0).udp_port(0)
@b.on_event("SHUTTER")
async def on_shutter(store, event):
store.set_value("SHUTTER:COUNT", store.get_value("SHUTTER:COUNT") + 1)
server = b.build()
server.start_background()
server.post_event("SHUTTER")
Handlers receive (store, event); the second argument lets one function
serve several events. They are deferred: post_event queues them and
returns. The only guarantee is that when post_event returns, any IOC
records on that event have processed and the handlers are queued — never
read "queued" as "run". Handlers run one at a time, in registration order,
across all events on a single dispatcher — a slow handler for one event
delays every other event's handlers behind it.
The record half of that guarantee is real, not aspirational: post_event
blocks the calling Python thread until every registered record consumer has
finished, releasing the GIL for the duration, so a consumer that itself
needs the GIL cannot deadlock against the poster. (When a tier-3 Ioc is
hosted, its event-scan list is such a consumer: records with SCAN="Event"
process before post_event returns.)
A handler that raises is logged and skipped; the next handler still runs
(unlike a raising on_start, which aborts startup — see above). The queue
is bounded (1024 entries); under sustained overload, invocations are
dropped with a counter rather than growing without limit. Posting an event
with no registered handlers is a no-op, not an error.
server.drain_events() blocks until the queue is empty. It exists so tests
can wait for handlers deterministically; production code should not need
it, since it does not know when a handler will next need to fire. Calling
it with handlers queued but the server never started fails immediately and
says so, rather than waiting out the 10 s drain timeout.
post_event takes a name only. Data travels through PVs, where it is
observable and visible to both stores.
Normative Type classes
Full-fidelity payloads for Store.get_nt/put_nt, sources, and the notifier.
nt = spvirit.NtScalar(
42.5,
units="mm",
display_low=0.0, display_high=100.0,
display_description="Position", display_precision=3,
control_low=0.0, control_high=90.0, control_min_step=0.1,
alarm_severity=0, alarm_status=0, alarm_message="",
)
nt.value # 42.5 (all properties are read-only)
nt.units # "mm"
nt.alarm_severity # 0
nt.value_type # "double"
count = spvirit.NtScalar(1000, type="long")
count.value_type # "long"
NtScalar(value, units="", display_low=0.0, display_high=0.0, display_description="", display_precision=0, control_low=0.0, control_high=0.0, control_min_step=0.0, alarm_severity=0, alarm_status=0, alarm_message="", type=None)— scalar with display/control/alarm metadata.type=picks the wire value type explicitly (same type-string convention and strict coercion asspvirit.scalar(...)); omitted, the type is inferred fromvaluethe same wayspvirit.pv(...)infers it..value_typereturns the canonical wire type name, e.g."double","ushort".NtScalarArray(value, type=None)— array payload (list or bytes);type=picks the element type explicitly. Exposes.value,.value_type,.alarm,.time_stamp,.display,.control.NtTable(columns, *, labels=None, types=None, descriptor=None)— build a table from a{column_name: list}dict;labelsdefaults to the column names,typesoptionally maps column name to a value-type string..labels,.columns() -> dict[str, list],.column_types() -> dict[str, str],.descriptor,.alarm,.time_stamp(the latter twoNoneunless the table came from a read).NtNdArray(value, dims, *, type=None)— build an N-D array from flat data and a list of per-dimension sizes (offset 0, binning 1, not reversed);type=picks the element type explicitly..value,.value_type,.dimensions()(list of dicts withsize/offset/full_size/binning/reverse),.unique_id,.compressed_size,.uncompressed_size,.data_time_stamp.- Substructure classes, each with read-only properties matching their
constructor arguments:
Alarm(severity=0, status=0, message="")TimeStamp(seconds_past_epoch=0, nanoseconds=0, user_tag=0)Display(limit_low=0.0, limit_high=0.0, description="", units="", precision=0)Control(limit_low=0.0, limit_high=0.0, min_step=0.0)
- Enum and generic-structure payloads are represented as plain dicts:
enums as
{"index": int, "choices": [...], "selected": str}, generic structures as{"struct_id": ..., <field>: <value>, ...}.
Client API
High-level client for talking to any PVAccess server (spvirit or otherwise):
import spvirit
client = spvirit.Client() # defaults: broadcast search
result = client.get("SIM:TEMP") # GetResult
result.value # decoded value (dict for NT structures)
result.pv_name # "SIM:TEMP"
result.raw_pva, result.raw_pvd # raw wire bytes, if you need them
client.get("SIM:TEMP", fields=["value", "alarm.severity"]) # partial pvRequest
client.get("SIM:TEMP", fields="value") # single field: plain str is fine too
client.put("SIM:SP", 21.0) # blocking put (fields default: ["value"])
client.put("SIM:MODE", 2, fields=["value.index"]) # NTEnum: selects choice 2 of an mbbo
def on_update(update): # a MonitorUpdate, not a bare value
print(update.value)
if done:
return False # returning False stops the monitor
client.monitor("SIM:TEMP", on_update) # blocks until stopped;
# an exception raised in the
# callback stops it and propagates
client.info("SIM:TEMP") # {"struct_id": ..., "fields": [...]}
client.pvlist("192.168.1.10:5075") # PV names from a specific server
Every monitor callback — Client.monitor, Client.subscribe and
Channel.monitor — receives a spvirit.lowlevel.MonitorUpdate:
| Attribute | Meaning |
|---|---|
.value |
the decoded value, the dict these callbacks used to receive directly |
.changed |
dotted paths of the fields this update carries, e.g. ["value", "timeStamp.secondsPastEpoch"] |
.overrun |
dotted paths of fields the server dropped at least one earlier update for |
.has_overrun |
True when .overrun is non-empty |
"<whole structure>" appears in .changed / .overrun when bit 0 is set —
the server is reporting the entire structure rather than named fields.
def on_update(update):
print(update.value["value"])
if update.has_overrun:
print("dropped updates for:", update.overrun)
Breaking change (0.1.20). Monitor callbacks used to receive the decoded value itself. Add
.valueto restore the previous behaviour:lambda v: print(v)becomeslambda u: print(u.value).
Non-blocking monitors — subscribe returns immediately with a
Subscription handle while updates are delivered on a background thread:
sub = client.subscribe("SIM:TEMP", lambda u: print(u.value))
# ... program continues; run as many concurrent subscriptions as you like ...
sub.pv_name # "SIM:TEMP"
sub.is_active # True while updates are flowing
sub.close() # stop promptly (idempotent, works even on a quiet PV)
sub.error # None, or the message if the subscription ended on an error
with client.subscribe("SIM:PRESSURE", handle) as sub: # context manager
...
- The callback receives each update sequentially (per subscription) on a
runtime worker thread; keep it short, and hand heavy work to a queue.
Returning
Falseunsubscribes, exactly likemonitor. - Inside the callback you may call other spvirit operations (
pv.set(),client.get(), …) — re-entrancy is safe. - Failures don't raise (there is no caller to raise into): on a network error
or an exception in the callback, the subscription ends,
is_activebecomesFalse, anderrorholds the message. - Dropping the last reference to a
Subscriptioncloses it — keep the handle alive for as long as you want updates.
Configure with the builder when defaults don't fit:
client = (
spvirit.Client.builder()
.server_addr("127.0.0.1:5075") # skip UDP search, connect directly
.search_addr("192.168.1.255") # or: unicast/broadcast search target
.name_server("10.0.0.5:5075") # or: TCP name server
.udp_port(5076) # search port (default 5076)
.port(5075) # TCP port (default 5075)
.timeout(2.0) # operation timeout (default 5.0 s)
.no_broadcast(True) # disable broadcast search
.bind_addr("0.0.0.0")
.authnz_user("ops") # AUTHZ identity
.authnz_host("console1")
.debug(True) # wire-level logging
.build()
)
Server discovery (UDP beacons):
for srv in spvirit.py_discover_servers(timeout=2.0):
print(srv.guid.hex(), srv.tcp_addr)
(spvirit.lowlevel has its own discover_servers for diagnostic use — note
it returns {"guid": hexstr, "addr": ...} dicts rather than
DiscoveredServer objects, defaults to timeout=1.0, and accepts a
targets= list.)
All client operations block with the GIL released and raise the
SpviritError exception tree on failure. Every
fields= parameter across the client API accepts either a list of dotted
paths or a single string.
Low-level API: spvirit.lowlevel
For repeated operations on one PV, protocol inspection, or precise control,
spvirit.lowlevel.Channel keeps a persistent TCP connection:
from spvirit.lowlevel import Channel
with Channel.connect("SIM:TEMP", "127.0.0.1:5075", timeout=5.0) as ch:
ch.pv_name, ch.is_open, ch.server_addr, ch.sid
desc = ch.introspect() # StructureDesc (see codec section)
r1 = ch.get() # reuses the connection — fast repeated gets
r2 = ch.get(fields=["value"])
ch.put(22.0) # fields: None -> ["value"], or str, or list
ch.monitor(lambda u: print(u.value))
# blocks; the callback gets a MonitorUpdate
# and returns False to stop, a raised
# exception stops it and propagates
ch.close() # or rely on the context manager;
# later operations raise ProtocolError
# async variants: connect_async, get_async, put_async, introspect_async,
# read_packet_async (monitor, close, and read_until are sync-only)
Raw frame access for protocol work:
pkt = ch.read_packet(timeout=1.0) # next raw PVA frame -> Packet
pkt.magic, pkt.version, pkt.command # raw header fields (ints)
pkt.command_name, pkt.payload_length # e.g. "MONITOR", body length
pkt.flags # raw flag byte; decoded views:
pkt.is_application, pkt.is_control, pkt.is_client, pkt.is_server, pkt.is_msb
pkt.is_segmented # segmentation flag bits (int, 0 = none)
pkt.bytes, pkt.payload, len(pkt) # full frame / payload bytes / frame len
pkt.details() # command-specific decoded dict
pkt.decode() # same as codec.decode_packet(pkt.bytes)
pkt = ch.read_until(lambda p: p.command_name == "MONITOR", timeout=5.0,
max_frames=100) # RuntimeError if max_frames exhausted
Breaking change (0.1.20).
read_packet/read_packet_async/read_untilnow reassemble segmented messages before returning. A segmented message arrives as onePacketwhoseis_segmentedis0and whosepayload_lengthcovers the concatenated payload — the individual segments are never surfaced. Code that stitched segments together by hand must stop doing so; it would now concatenate whole messages. Control frames (echo, flow control) still pass through one frame at a time and are unaffected.
Operations on one channel serialize internally, so concurrent get_async
calls on the same channel are safe but sequential on the wire. Monitors send
protocol echo keep-alives automatically.
Discovery utilities (from spvirit.lowlevel import ...), all with *_async
variants where noted:
search_pv(pv_name, udp_port=5076, timeout=3.0, targets=None, debug=False) -> "ip:port"— UDP broadcast search (async:search_pv_async).search_pv_tcp(pv_name, name_server, timeout=3.0, debug=False) -> "ip:port"— search via a TCP name server.discover_servers(udp_port=5076, timeout=1.0, targets=None, debug=False) -> [{"guid": hexstr, "addr": "ip:port"}](async:discover_servers_async).pvlist(server_addr, timeout=5.0) -> (names, source)wheresourcesays how the listing was obtained ("pvlist","getfield","server_rpc","server_get") (async:pvlist_async).parse_addr_list(s),auto_broadcast_targets(),default_search_targets(search_addr=None, bind_addr=None)— address-list helpers honoringEPICS_PVA_ADDR_LIST/EPICS_PVA_AUTO_ADDR_LIST.
targets= accepts a list of (target_ip, bind_ip) tuples or of
{"target": ..., "bind": ...} dicts, so the output of
default_search_targets() / auto_broadcast_targets() can be passed
straight through.
Wire codec: spvirit.codec
Standalone encoders/decoders for PVAccess wire data — useful with
Channel.read_packet, captured traffic, or tests:
from spvirit import codec
desc = codec.decode_introspection(intro_bytes) # -> StructureDesc
print(desc.dump()) # readable multi-line layout
desc.struct_id # e.g. "epics:nt/NTScalar:1.0"
[f.name for f in desc.fields] # FieldDesc: .name, .field_type,
"value" in desc, desc.field("value") # .type_code, .is_array, .struct_desc
value = codec.decode_value(data_bytes, desc) # decode pvData -> Python
codec.format_value(value) # compact one-line rendering
codec.extract_nt_value(value) # pull `value` out of an NT dict
codec.encode_pv_request(["value", "alarm"]) # pvRequest bytes (None/empty = all)
codec.encode_put_payload(desc, {"value": 42.0}) # bitset + data for a PUT
codec.decode_packet(frame_bytes) # full frame -> dict with magic,
# command_name, flags, details, ...
The wire-level encoders/decoders (decode_introspection, decode_value,
encode_pv_request, encode_put_payload) accept is_be=True for big-endian
streams (default little); format_value, extract_nt_value, and
decode_packet take no endianness flag. StructureDesc also supports
len(desc) for its field count.
Threading and async model
-
The extension runs one shared multi-threaded Tokio runtime per process, created lazily. Blocking methods release the GIL, so other Python threads keep running during network operations.
-
Sync and async mix freely.
set()/get()/Client.get()etc. can be called from any Python thread. The async variants (Pv.set_async/get_async,Channel.connect_async/get_async/put_async, …) return awaitables for use insideasynciocode:async def main(): await sp.set_async(21.0) print(await temp.get_async()) asyncio.run(main())
-
Callbacks are re-entrant.
on_put, scan, and calc callbacks run on runtime worker threads while holding the GIL only as needed; inside them you may call blocking handle methods on other PVs (e.g. update a readback from a setpoint'son_put). -
Server.start()serves from a background thread;Server.run()occupies the calling thread. Pythonasync defsource methods execute on a dedicated asyncio loop thread managed by the extension. -
Long-running Python callbacks stall the worker they run on — keep
on_putand scan bodies short, and push slow work to your own threads, publishing results withpv.set(...)ornotifier.notify(...).
Errors and exceptions
Client, channel, discovery, and codec operations raise this hierarchy
(rooted at spvirit.SpviritError):
| Exception | Meaning |
|---|---|
spvirit.TimeoutError |
operation or search timed out (also subclasses builtin TimeoutError) |
spvirit.SearchError |
PV could not be located |
spvirit.ProtocolError |
unexpected/invalid protocol state |
spvirit.DecodeError |
malformed wire data |
spvirit.IoError |
socket/OS-level failure (also subclasses builtin OSError) |
spvirit.PutRejectedError |
reserved; not currently raised. A put that the server refuses (an on_put validator, Write access denied, a value not representable in the record's type) raises spvirit.ProtocolError with the server's message. |
spvirit.TimeoutError and spvirit.IoError deliberately dual-inherit, so
idiomatic except TimeoutError: and except OSError: catch them alongside
except spvirit.SpviritError:.
PV-handle operations use builtin exceptions where a builtin is the idiomatic fit:
| Exception | Raised when |
|---|---|
RuntimeError |
handle not yet bound to a server; ServerBuilder method after build() |
KeyError |
server.pv(name) for an unknown or unsupported record |
TypeError |
wrong value type; wrong-kind value for a type=-selected wire type (e.g. str into an int type, a fractional float into an int type, bool into a non-boolean type); on_put/scan on an array PV; bad calc inputs; metadata options on an array pv() |
ValueError |
invalid address strings; non-finite floats in client puts; unknown type=/types= value-type string |
OverflowError |
a type=-coerced value is out of range for the target wire type |
Examples
Runnable scripts live in
spvirit-py/examples/.
Start with the mailbox pair, then pick by topic:
demo_mailbox.py/demo_mailbox_client.py— the smallest useful IOC (one writable PV) and a client that gets, puts, and subscribes to it.demo_pvfind.py— locate a PV (UDP search or a given address) and print its full structure introspection and current value.demo_wire_inspector.py— decode the actual PVAccess frames behind a get: introspection dump, captured raw bytes throughcodec.decode_packet, and live frames off the TCP stream.demo_gateway.py— a mini-gateway: a dynamic source that proxies another server under aGW:prefix with a put allow-list.demo_expr_namespace.py— an infinite computed namespace: anyEXPR:<expression>PV is evaluated on demand.demo_10k_farm.py— 10,000 PVs plus calc aggregation built in a comprehension, with build/serve/write timings.
The directory also holds demos for every other part of the API (channels, codec, discovery, NT access, and one per dynamic-source pattern).
Building from source
Requires Rust (stable) and Python ≥ 3.9.
git clone https://github.com/ISISNeutronMuon/spvirit
cd spvirit/spvirit-py
python -m venv .venv && . .venv/bin/activate # .venv\Scripts\activate on Windows
pip install maturin
maturin develop --release # build + install into the venv
python tests/test_pv_handles.py
Releases are cut by tagging spvirit-py-vX.Y.Z; CI builds abi3 wheels for all
supported platforms plus an sdist and publishes to PyPI via trusted
publishing.
License
BSD-3-Clause. Developed at the ISIS Neutron and Muon Source.
Metadata
Release files for spvirit 0.4.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 | |
|---|---|---|---|---|
| spvirit-0.4.0.tar.gz | 835.7 kB | With provenance | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| spvirit-0.4.0-cp39-abi3-win_amd64.whl | CPython 3.9 | abi3 | Windows x86-64 | Details |
| spvirit-0.4.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| spvirit-0.4.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.9 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| spvirit-0.4.0-cp39-abi3-macosx_11_0_arm64.whl | CPython 3.9 | abi3 | macOS 11.0+ ARM64 | Details |
| spvirit-0.4.0-cp39-abi3-macosx_10_12_x86_64.whl | CPython 3.9 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 20.4 MB
Release files / spvirit-0.4.0.tar.gz
| Download URL | spvirit-0.4.0.tar.gz |
|---|---|
| Size | 835.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1be0f2bd4e2181332da75e5c62cff4a4aa14490efb96d9ff1df34401e5efe2d1
|
|
BLAKE2b-256 checksum How to use checksums |
aed3afdb85a5934054c6f234139213fba0a76a18db7111e418fb09732e922aec
|
| 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 24, 2026.
Transparency logRelease files / spvirit-0.4.0-cp39-abi3-win_amd64.whl
| Download URL | spvirit-0.4.0-cp39-abi3-win_amd64.whl |
|---|---|
| Size | 3.8 MB |
| Tags | CPython 3.9 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
516f31c74f1f8637196ecf171856cdd3bf755ee0f9470e8f65219f8d92c25194
|
|
BLAKE2b-256 checksum How to use checksums |
b6f30ba7ce31df34360817ecd440d76e9571e50975d8e8d4611c6001f00be699
|
| 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 24, 2026.
Transparency logRelease files / spvirit-0.4.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | spvirit-0.4.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 4.1 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
d57a08ae946f4b3cb201646e0fe7ba22b69b959da9cc13d34f78cd3e666adca4
|
|
BLAKE2b-256 checksum How to use checksums |
e50fa65154479ec58c461a50fd41ede2067849fa85c869b6e542e63dd71331c8
|
| 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 24, 2026.
Transparency logRelease files / spvirit-0.4.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | spvirit-0.4.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 4.1 MB |
| Tags | CPython 3.9 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
d7db1977b8490def11d8da34847882d82364330f8f1134f7b206bbb642e347e2
|
|
BLAKE2b-256 checksum How to use checksums |
ce645f845bca619704734065159d22c03939435d7fcf60782bd73135c9fcf5b5
|
| 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 24, 2026.
Transparency logRelease files / spvirit-0.4.0-cp39-abi3-macosx_11_0_arm64.whl
| Download URL | spvirit-0.4.0-cp39-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 3.7 MB |
| Tags | CPython 3.9 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
eae8da26fe856b2e1c0f0f13976f99306ee5a7aef8da38dd346c46fd15125625
|
|
BLAKE2b-256 checksum How to use checksums |
71852f0fc54538cfb1a0aad757b1462c2306cba7a4c5e7c2e4541407b4a990fa
|
| 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 24, 2026.
Transparency logRelease files / spvirit-0.4.0-cp39-abi3-macosx_10_12_x86_64.whl
| Download URL | spvirit-0.4.0-cp39-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 3.9 MB |
| Tags | CPython 3.9 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
84f20204156ca853a16ea38a41313b5b46a4166888ec18012b76fad015bb8db0
|
|
BLAKE2b-256 checksum How to use checksums |
5dcf444f4fd5e56b69d8708acbf60e371db3210cb8b0751d0663908a453337fd
|
| 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 24, 2026.
Transparency logSecurity for spvirit 0.4.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 6 files in this release have been uploaded with provenance metadata.
Attestation for spvirit-0.4.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 24, 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.