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 a Command subclass and returns the bytes that should be sent to the PandA. Whenever bytes are received from the socket they can be passed to receive_bytes() which will return any subsequent bytes that should be send back. The responses() method returns an iterator of (command, response) tuples that have now completed. The response type will depend on the command. For instance Get returns bytes or a list of bytes of the field value, and GetFieldInfo returns a dict mapping str field name to FieldInfo.

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 to receive_bytes() which will return an iterator of Data objects. Intermediate FrameData can be squashed together by passing flush_every_frame=False, then explicitly calling flush() 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.