Why write a Sans-IO library?#
As the reference says: Reusability. The protocol can be coded in a separate class to the I/O allowing integration into a number of different concurrency frameworks.
For instance, we need both a BlockingClient
and an AsyncioClient
. If we had
coded the protocol in either of them it would not be usable in the other. Much
better to put it in a separate class and feed it bytes off the wire. We call
this protocol encapsulation a Connection.
Connections#
The PandA TCP server exposes a Control port and a Data port, so there are
corresponding ControlConnection
and DataConnection
objects:
- class pandablocks.connections.ControlConnection[source]
Sans-IO connection to control port of PandA TCP server, supporting a Command based interface. For example:
cc = ControlConnection() # Connection says what bytes should be sent to execute command to_send = cc.send(command) socket.sendall(to_send) while True: # Repeatedly process bytes from the PandA received = socket.recv() # Sending any subsequent bytes to be sent back to the PandA to_send = cc.receive_bytes(received) socket.sendall(to_send) # And processing the produced responses for command, response in cc.responses() do_something_with(response)
The
send()
method takes aCommand
subclass and returns the bytes that should be sent to the PandA. Whenever bytes are received from the socket they can be passed toreceive_bytes()
which will return any subsequent bytes that should be send back. Theresponses()
method returns an iterator of(command, response)
tuples that have now completed. The response type will depend on the command. For instanceGet
returnsbytes
or alist
ofbytes
of the field value, andGetFieldInfo
returns adict
mappingstr
field name toFieldInfo
.
- class pandablocks.connections.DataConnection[source]
Sans-IO connection to data port of PandA TCP server, supporting an flushable iterator interface. For example:
dc = DataConnection() # Single connection string to send to_send = dc.connect() socket.sendall(to_send) while True: # Repeatedly process bytes from the PandA looking for data received = socket.recv() for data in dc.receive_bytes(received): do_something_with(data)
The
connect()
method takes any connection arguments and returns the bytes that should be sent to the PandA to make the initial connection. Whenever bytes are received from the socket they can be passed toreceive_bytes()
which will return an iterator ofData
objects. IntermediateFrameData
can be squashed together by passingflush_every_frame=False
, then explicitly callingflush()
when they are required.
Wrappers#
Of course, these Connections are useless without connecting some I/O. To aid with
this, wrappers are included for use in asyncio
and blocking programs. They expose
slightly different APIs to make best use of the features of their respective concurrency frameworks.
For example, to send multiple commands in fields with the blocking
wrapper:
with BlockingClient("hostname") as client:
resp1, resp2 = client.send([cmd1, cmd2])
while with the asyncio
wrapper we would:
async with AsyncioClient("hostname") as client:
resp1, resp2 = await asyncio.gather(
client.send(cmd1),
client.send(cmd2)
)
The first has the advantage of simplicity, but blocks while waiting for data. The second allows multiple co-routines to use the client at the same time at the expense of a more verbose API.
The wrappers do not guarantee feature parity, for instance the flush_period
option is only available in the asyncio wrapper.