Inspirel banner

Programming Distributed Systems with YAMI4

9.2.5 Python

The Python client and server are simple scripts that only require the visibility of the YAMI4 implementation files from the place where the scripts are started - please see the chapter about library structure and compilation for instructions on how to ensure this is the case.

Assuming that the environment is properly set up, the following invocation is enough to start the server program with the shortest possible target:

$ python3 server.py 'tcp://*:*'

The client program is entirely implemented in the client.py file, which is dissected in detail below.

First the necessary import statements:

import sys
import yami

The yami module is the single module encapsulating all YAMI4 definitions.

The sys module is needed to read text from the standard input as well as to access the command-line program arguments.

Initial actions in the client program check whether there are three required arguments describing server destination address and two integers:

if len(sys.argv) != 4:
    print("expecting three parameters:",
          "server destination and two integers")
    exit()

server_address = sys.argv[1]

try:
    a = int(sys.argv[2])
    b = int(sys.argv[3])
except ValueError:
    print("cannot parse the second or third parameter")
    exit()

After the program arguments are processed, client can create its own YAMI agent.

try:
    with yami.Agent() as client_agent:

Then the parameters object is prepared that will be used as a payload of the outgoing message - here a simple dictionary object is used instead of the dedicated parameters class and the two integer values that were read from the command-line are placed in that dictionary with names ``a'' and ``b''. These names are recognized by the server.

        params = {"a":a, "b":b}

When the parameters object is filled with data, the actual outgoing message is created:

        with client_agent.send(
            server_address, "calculator", "calculate", params) as msg:

Above, the send() function is invoked with the following parameters:

The outgoing message object is created as a result of this operation. It is used within the context manager to ensure its proper cleanup.

It is important to note that the actual message is transmitted to the server in background and there is no provision for it to be already transmitted when the send() function returns. In fact, the user thread might continue its work while the message is being processed by the background I/O thread.

In this particular example, the client program does not have anything to do except waiting for the response:

            msg.wait_for_completion()

Depending on the message state, client either processes the result or prints appropriate report on the console.

In the most expected case the completion of the message is a result of receiving appropriate reply from the server - this can be checked with the REPLIED message state:

            state = msg.get_state()
            if state[0] == yami.OutgoingMessage.REPLIED:
                reply = msg.get_reply()

                sum = reply["sum"]
                difference = reply["difference"]
                product = reply["product"]

                if "ratio" in reply:
                    ratio = reply["ratio"]
                    ratio_defined = True
                else:
                    ratio_defined = False

Above, the ratio_defined variable is True when the reply content has the entry for the result of division (and then the ratio variable will get that value), and False otherwise. This reflects the ``protocol'' of interaction between client and server that allows the server to send back the ratio only when it can be computed.

The code then prints all received values on the console:

                print("sum        =", sum)
                print("difference =", difference)
                print("product    =", product)

                print("ratio      = ", end="")
                if ratio_defined:
                    print(ratio)
                else:
                    print("<undefined>")

Alternatively, the message might be completed due to rejection or being abandoned - these cases are reported as well:

            elif state[0] == yami.OutgoingMessage.REJECTED:
                print("The message has been rejected:",
                      msg.get_exception_msg())
            else:
                print("The message has been abandoned.")

The outgoing message manages some internal resources that need to be properly cleaned up. In this example, the whole program sends only one message, so both the agent and the message need to be cleaned up after they are no longer needed. This is ensured by context managers - alternatively, the programmer would have to manually call their close() methods.

In more complex systems a single agent will be used for processing many messages, so the clean-up pattern will be different as well.

The client program finishes with simple exception handling.

except Exception as e:
    print("error:", e)

The server program is entirely implemented in the server.py file, which begins with a typical set of imports:

import sys
import yami

Similarly to the client code, the server first checks whether it was started with necessary arguments. Here the only expected argument is the server listener address:

if len(sys.argv) != 2:
    print("expecting one parameter: server destination")
    exit()

server_address = sys.argv[1]

One of the most important server-side entities is a message handler - any callable entity that accepts the incoming message object as the parameter of invocation can be used in Python. For the sake of simplicity, the calculator example uses a simple global call() function for this purpose:

def call(msg):

The handler has to extract the content of the message, which is a regular parameters object. In the calculator system the content is supposed to have two integer entries named ``a'' and ``b'':

    # extract the parameters for calculations

    params = msg.get_parameters()

    a = params["a"]
    b = params["b"]

The server computes the results of four basic calculations on these two numbers, with the possible omission of the ratio, which might be impossible to compute if the divisor is zero. In this case the ratio entry is simply not included in the resulting parameters object - note that the possibility of missing entry in the message response is properly handled by the client and this is part of the calculator ``protocol''.

    # prepare the answer with results of four calculations

    reply_params = {"sum":a+b, "difference":a-b, "product":a*b}

    # if the ratio cannot be computed,
    # it is not included in the response
    # the client will interpret that fact properly
    if b != 0:
        reply_params["ratio"] = int(a / b)

Once the parameters object for the reply is prepared, the incoming message can be replied to:

    msg.reply(reply_params)

The handler finishes with a report on the console.

    print("got message with parameters", a, "and", b, 
          ", response has been sent back")


The main executable part of the server program creates the agent object and adds a new listener on the location given by command-line parameter:

try:
    with yami.Agent() as server_agent:

        resolved_address = server_agent.add_listener(server_address)

        print("The server is listening on", resolved_address)

The previously defined message handler is registered as an implementation of the logical object named ``calculator''. This object name has to be known by clients so that they can properly send their messages.

        server_agent.register_object("calculator", call)

The server's main thread effectively stops by performing a dummy input operation. In this state the whole activity of the server program is driven by incoming messages that are received in background.

        # block
        dummy = sys.stdin.read()

The server code finishes with simple exception handling:

except Exception as e:
    print("error:", e)