The Robot World Repo on GitHub
The Forth Repo on GitHub

I’ve decided to start from a simple server from Real Python, and to evolve to something that makes sense to me. I have high hopes for this approach. Also: Love one another.

It’s kind of my strength, really, starting small and growing into something that has a decent structure and is understandable—at least by my lights. I’ll refer as I go to the larger Real Python examples, all of which I have running in PyCharm, and, well, we’ll see where we go. Maybe we’ll get all the way to a server for the Robot World. Maybe far enough to see how to do it. Maybe we’ll crash and burn, but I don’t expect that.

Yesterday, I had a little free time and got this little test working:

import socket

class TestServer:
    def test_hookup(self):
        assert True

    def test_one_go_round(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.connect(("127.0.0.1", 34567))
            s.sendall(b"Hello, world")
            data = s.recv(1024)

        assert data == "I saw: Hello, world".encode('utf-8')

This test runs, if first I start this, my initial server:

import socket

class WorldServer:
    HOST = "127.0.0.1"
    PORT = 34567

    def go(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.bind((self.HOST, self.PORT))
            s.listen()
            conn, addr = s.accept()
            with conn:
                print(f"Connected by {addr}")
                while True:
                    data = conn.recv(1024)
                    if not data:
                        break
                    conn.sendall(b'I saw: ' + data)

ws = WorldServer()
ws.go()

I think I can nearly explain how this works. It opens a socket with with, attaches it to the given host and port addresses, and listens to it. Then it calls accept, which blocks until someone (our test) connects, and then returns the connection and address (I really don’t know what those are, but I can look it up: conn is a socket, and addr is a tuple containing some host and port info that apparently We do not need other than to print.)

Using with conn we open the connection, and enter a loop where we read data from the connection with recv. If we get none, it means that the client has closed the connection, so we break out and the server program ends. If we do get data, we send back “I saw: “ and whatever we read.

So far so good. I happen to know that this test isn’t really valid. In principle, if I understand what Ive read, the socket is not guaranteed to provide the entire message in one go. If that were to happen, we could see something like “I saw: hell” in our test instead of “I saw: Hello, world”. Subsequent versions will deal with this possibility.

Given that I start the server first, this test runs. Life is good.

What shall we do next? Here’s a rough plan that I have come up with since yesterday’s success of getting that one test to run:

Move toward a server that keeps on serving, and that can process more than one connection. (I’m not sure whether we’ll keep individual connections open over more than one cycle, but I believe that’s where we want to go.) I think we’ll provide a little dictionary or something, that the server uses to look up responses to different messages we might send it, so that our tests can have a little conversation with the server.

I then imagine that we can evolve the dictionary to a smart object that given the input message, produces the output message. And from there it should be just a few easy steps to having that smart object be our Robot World server. (Perhaps for large values of “few” and small values of “easy”.)

OK, then. I’m curious: if our test had sent two messages, would they both work? Let’s try another test against this same version of the server.

    def test_two_messages(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.connect(("127.0.0.1", 34567))
            s.sendall(b"Hello, world")
            data = s.recv(1024)
            assert data == "I saw: Hello, world".encode('utf-8')
            s.sendall(b'How ya doing?')
            data = s.recv(1024)
            assert data == "I saw: How ya doing?".encode('utf-8')

I had to turn off the other test, because our server is closing out on absent data, so the first test would close it. Essentially I’ve replaced the first test with this more stringent one. And it runs.

Commit: passing simple test with two messages.

Reflection

If you’re familiar with sockets, you may be grinning and pointing fingers at me as I inch forward from abysmally ignorant toward just terribly ignorant. If so, I can handle it, because I suspect there was a day in your past when you didn’t understand them either. And by the time I’m done here, I expect to understand a lot about them, and it won’t just be that I found some code to copy and paste: I’m building up real understanding, bit by bit.

Could I go faster? Certainly. For one thing the work above took me about a minute and the writing has taken a half hour or something. But that’s not a fair comparison either, because some of the writing time is planning and thinking time, and I’d do that even if you weren’t here reading over my shoulder.

What’s next?

Our server is blocking on the accept, and on the recv, and that won’t do if we have more than one connection.

Possibly, if we do this right, we can get our two tests, the single-message one and the two-message one, both running, because our server won’t quit. In fact, pretty soon, I hope, it will only quit when we want to enhance its behavior, otherwise running all the time. As things stand now, every time I type, the tests fail because the server is down. It would be nice if every time I type, they were to run green.

I’m going to steal code from the Real Python multiple connection example now. I have that running in another PCharm project, and the server there looks like this:


import selectors
import socket
import sys
import types

sel = selectors.DefaultSelector()


def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print(f"Accepted connection from {addr}")
    conn.setblocking(False)
    data = types.SimpleNamespace(addr=addr, inb=b"", outb=b"")
    events = selectors.EVENT_READ | selectors.EVENT_WRITE
    sel.register(conn, events, data=data)


def service_connection(key, mask):
    sock = key.fileobj
    data = key.data
    if mask & selectors.EVENT_READ:
        recv_data = sock.recv(1024)  # Should be ready to read
        if recv_data:
            data.outb += recv_data
        else:
            print(f"Closing connection to {data.addr}")
            sel.unregister(sock)
            sock.close()
    if mask & selectors.EVENT_WRITE:
        if data.outb:
            print(f"Echoing {data.outb!r} to {data.addr}")
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]


if len(sys.argv) != 3:
    print(f"Usage: {sys.argv[0]} <host> <port>")
    sys.exit(1)

host, port = sys.argv[1], int(sys.argv[2])
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.bind((host, port))
lsock.listen()
print(f"Listening on {(host, port)}")
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)

try:
    while True:
        events = sel.select(timeout=None)
        for key, mask in events:
            if key.data is None:
                accept_wrapper(key.fileobj)
            else:
                service_connection(key, mask)
except KeyboardInterrupt:
    print("Caught keyboard interrupt, exiting")
finally:
    sel.close()

There’s lot of stuff there and we’ll need to deal with most of it. I note that their server is running as main, while ours is implemented as a class. Python folks seem to be more tolerant of things built as top-level functions than I am. But that’s the easy part anyway.

I think that first thing, I’ll move all that bottom stuff into my go method. That probably won’t even compile but it gives us a change to think about and deal with smaller chunks. The objective will be to get our two existing tests to run on the new version of WorldServer that we’re making.

The first cut is this:

    def go(self):
        sel = selectors.DefaultSelector()
        lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        lsock.bind((self.HOST, self.PORT))
        lsock.listen()
        print(f"Listening on {(self.HOST, self.PORT)}")
        lsock.setblocking(False)
        sel.register(lsock, selectors.EVENT_READ, data=None)

        try:
            while True:
                events = sel.select(timeout=None)
                for key, mask in events:
                    if key.data is None:
                        accept_wrapper(key.fileobj)
                    else:
                        service_connection(key, mask)
        except KeyboardInterrupt:
            print("Caught keyboard interrupt, exiting")
        finally:
            sel.close()

I’m missing the accept_wrapper and service_connection. Let’s just bring them in as methods.

OK, that was slightly tedious but straightforward. Before I waste lines and your eyes reading code that may not work, I’ll start the server and see what happens.

What happens is that the tests fail because the new code does not include the “I saw:” that I put into the previous “server”. Let’s see what I’ve done and decide what to do about the “I saw:”

class WorldServer:
    HOST = "127.0.0.1"
    PORT = 34567

    def __init__(self):
        self.sel = selectors.DefaultSelector()

    def accept_wrapper(self, sock):
        conn, addr = sock.accept()  # Should be ready to read
        print(f"Accepted connection from {addr}")
        conn.setblocking(False)
        data = types.SimpleNamespace(addr=addr, inb=b"", outb=b"")
        events = selectors.EVENT_READ | selectors.EVENT_WRITE
        self.sel.register(conn, events, data=data)


    def service_connection(self, key, mask):
        sock = key.fileobj
        data = key.data
        if mask & selectors.EVENT_READ:
            recv_data = sock.recv(1024)  # Should be ready to read
            if recv_data:
                data.outb += recv_data
            else:
                print(f"Closing connection to {data.addr}")
                self.sel.unregister(sock)
                sock.close()
        if mask & selectors.EVENT_WRITE:
            if data.outb:
                print(f"Echoing {data.outb!r} to {data.addr}")
                sent = sock.send(data.outb)  # Should be ready to write
                data.outb = data.outb[sent:]

    def go(self):
        lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        lsock.bind((self.HOST, self.PORT))
        lsock.listen()
        print(f"Listening on {(self.HOST, self.PORT)}")
        lsock.setblocking(False)
        self.sel.register(lsock, selectors.EVENT_READ, data=None)

        try:
            while True:
                events = self.sel.select(timeout=None)
                for key, mask in events:
                    if key.data is None:
                        self.accept_wrapper(key.fileobj)
                    else:
                        self.service_connection(key, mask)
        except KeyboardInterrupt:
            print("Caught keyboard interrupt, exiting")
        finally:
            self.sel.close()

ws = WorldServer()
ws.go()

So this is good news. Let’s see about plugging in the “I saw:” as a step on the way to results that depend on the input. We see that this is where the data.outb is set up:

    def service_connection(self, key, mask):
        sock = key.fileobj
        data = key.data
        if mask & selectors.EVENT_READ:
            recv_data = sock.recv(1024)  # Should be ready to read
            if recv_data:
                data.outb += recv_data

Now I believe that this code is prepared for a read that comes in more than one chunk, so we can’t just put “I saw:” in front of each read. I think we’ll try this:

    def service_connection(self, key, mask):
        sock = key.fileobj
        data = key.data
        if data.outb == b"":
            data.outb = b'I saw: '

That does not work as intended:

(b'I saw: Hello, worldI saw: I saw: I saw: I saw: I saw: I saw: I saw: I saw: I'
 b' saw: ') != b'I saw: Hello, world'

Not quite what I had in mind. I think I’ll change the tests not to expect the “I saw” bit and get to green.

class TestServer:
    def test_one_go_round(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.connect(("127.0.0.1", 34567))
            s.sendall(b"Hello, world")
            data = s.recv(1024)

        assert data == "Hello, world".encode('utf-8')

    def test_two_messages(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.connect(("127.0.0.1", 34567))
            s.sendall(b"Hello, world")
            data = s.recv(1024)
            assert data == "Hello, world".encode('utf-8')
            s.sendall(b'How ya doing?')
            data = s.recv(1024)
            assert data == "How ya doing?".encode('utf-8')

What is quite good is that the server stayed running from the two tests failing, to the first one working, to the first assertion of the second one working, to both tests working.

Now I’d like to find out whether the receive is getting the whole message all at once, or what. With another print added, here is the report from the server:


Listening on ('127.0.0.1', 34567)
Accepted connection from ('127.0.0.1', 50035)
Received b'Hello, world'
Echoing b'Hello, world' to ('127.0.0.1', 50035)
Closing connection to ('127.0.0.1', 50035)
Accepted connection from ('127.0.0.1', 50036)
Received b'Hello, world'
Echoing b'Hello, world' to ('127.0.0.1', 50036)
Received b'How ya doing?'
Echoing b'How ya doing?' to ('127.0.0.1', 50036)
Closing connection to ('127.0.0.1', 50036)
Caught keyboard interrupt, exiting

What if we were to init that SimpleNamespace differently?

    def accept_wrapper(self, sock):
        conn, addr = sock.accept()  # Should be ready to read
        print(f"Accepted connection from {addr}")
        conn.setblocking(False)
        data = types.SimpleNamespace(addr=addr, inb=b"", outb=b"I saw: ")
        events = selectors.EVENT_READ | selectors.EVENT_WRITE
        self.sel.register(conn, events, data=data)

Now we can put the “I saw: “ back into all the tests:

Ah! That doesn’t quite work, because we don’t get the “I saw” on the second message on the same connection.

I tried updating data.outb after sending it, if it was empty. That resulted in a broken pipe message and other such things. As things stand, the first test passes and this version of the second:

    def test_two_messages(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.connect(("127.0.0.1", 34567))
            s.sendall(b"Hello, world")
            data = s.recv(1024)
            assert data == "I saw: Hello, world".encode('utf-8')
            s.sendall(b'How ya doing?')
            data = s.recv(1024)
            assert data == "How ya doing?".encode('utf-8')

We don’t get the “I saw” on the “How ya doing?” echo. Let’s go back to the legitimate version without the “I saw”, and work from there. We’ll commit that: Server runs continually and echos to multiple connections.

We’ll reflect and sum up:

Summary

Plugging in a slightly modified version of the Real Python multi-connection server has worked, giving us an echo server that can process multiple connections and multiple sends on a given connection.

I believe that it still has the issue that I mentioned early on: it is just echoing and does not recognize the end of a given message. Our tests show that our fist message is entirely echoed back before we send our second, but I think that this code has no notion of where one message stopped and another started:

    def service_connection(self, key, mask):
        sock = key.fileobj
        data = key.data
        if mask & selectors.EVENT_READ:
            recv_data = sock.recv(1024)  # Should be ready to read
            if recv_data:
                print(f'Received {recv_data}')
                data.outb += recv_data
            else:
                print(f"Closing connection to {data.addr}")
                self.sel.unregister(sock)
                sock.close()
        if mask & selectors.EVENT_WRITE:
            if data.outb:
                print(f"Echoing {data.outb!r} to {data.addr}")
                sent = sock.send(data.outb)  # Should be ready to write
                data.outb = data.outb[sent:]

If it were to happen that the read only got a partial message, that part would be put into data.outb, and since WRITE is always possible (I believe), that partial message would be sent out. And not all of it is guaranteed to be sent: that’s why we get the number of characters back in sent and remove that many characters from data.outb.

I’ll do a bit more reading and perhaps we’ll borrow some code or ideas from Real Python, but the next step will be to provide a character count or something that allows us to detect the end of a message, and at that point we’ll set up an output buffer. I think I could do that right now but I’m a bit tired and will benefit from some thinking.

We’ve made quite a bit of progress. We have our accept_wrapper method that makes an input and output buffer for each individual connection. I think there we will plug in a smarter object. And we have our service_connection method that reads data into and write data out of our SimpleNamespace.

There is a bit of hackery going on here which I have not yet understood or explained, namely what’s going on here:

try:
    while True:
        events = self.sel.select(timeout=None)
        for key, mask in events:
            if key.data is None:
                self.accept_wrapper(key.fileobj)
            else:
                self.service_connection(key, mask)

What is key? If we look in service_connection, we see this:

    def service_connection(self, key, mask):
        sock = key.fileobj
        data = key.data

sock is clearly the socket we created on the connection, since we send it recv and send. And data appears to be our SimpleNamespace, since the subsequent code refers to outb. Is inb ever referred to? Apparently not, I just removed it and the tests run. I suppose what we might do, instead of copying data from the recv directly to outb, is we might copy it into inb and then subsequently use it to compute outb. Perhaps we’ll see something like that next time.

So, I feel good about this. I feel like I have actually participated in building what we have here, instead of just copying it and my tests and modifications thereof give me confidence that I understand quite a bit about how it all works.

For me, this is a lot more satisfying than just copying, pasting, briefly testing manually, and then backing away slowly.

See you next time! Love one another!