Callback
Today we’ll address the need to provide details for Bots to be created. We’ll use a callback: it seems sensible to me.
Yesterday, I took an initial cut at creating Bots by telling the Cohort how many we needed, and the Cohort sending the ‘add_bot’ action over the fake wire to the World. I had forgotten that the World expected x, y and facing direction for each Bot, so, after some quick scrambling, the repo was left working, but not right. Today, we’ll see about making it right.
Here are the last three commit messages from yesterday:
- game works, World slightly hacked to accommodate game expectations and defect in dealing with direction. Cohort needs a way to specify where bots should go.
- fix defect where ‘add_bot’ set direction incorrectly.
- add_bot command defaults to 10, 20, EAST, Game works now
Not a debacle by any means, but not exactly award material either. Our plan for today is for Cohort.add_bots
to accept a new parameter, a callback lambda, that will be called once per new bot to be created, which is to return the x, y, and (string) direction facing for the bot. The callback function will be given two parameters, the index of the bot whose details are being requested, and the number of bots to be created (the input parameter to ‘add_bots’.)
By the way, please try to be a bit more helpful than the help I received yesterday. I feel as if better help than I got from my rubber duck would have saved us some trouble.
We’ll begin with a quick review of the code, and then I think we’d do well to write a test, even though this is surely so simple that we could just type it in, light the fuse, and walk away.
class Cohort:
def __init__(self, bot=None):
self.bots = {}
if bot:
self.bots[bot.id] = bot
self._bots_to_add = 0
def add_bots(self, number):
self._bots_to_add += number
def create_message(self):
message = []
self.add_desired_bots(message)
self.get_existing_bot_actions(message)
return message
def add_desired_bots(self, message):
for _ in range(self._bots_to_add):
action = {'entity': 0,
'verb': 'add_bot',
'x': 10,
'y': 20,
'direction': 'EAST'
}
message.append(action)
self._bots_to_add = 0
...
There’s the relevant code in Cohort: it is just creating all the Bots at 10, 20, EAST, which is what the Game class wants.
Let’s find senders of add_bots
. There won’t be many.
class TestCohort:
def test_adding_bots(self):
cohort = Cohort()
cohort.add_bots(5)
message = cohort.create_message()
assert len(message) == 5
for msg in message:
assert msg['verb'] == 'add_bot'
assert cohort._bots_to_add == 0
game.py
if __name__ == "__main__":
world = World(40, 40)
build_random_blocks()
game = Game(world)
connection = DirectConnection(world)
cohort = Cohort()
cohort.add_bots(20)
game.on_execute()
Let’s enhance the test by Wishful Thinking, aka By Intention, providing an easy to check pattern. Our callback will give us the index of the bot being created and the number requested. Let’s imagine that we want to have a table of values for the bot construction. We might do this:
def test_adding_bots(self):
bot_values = [
(5, 10, 'EAST'),
(10, 17, 'NORTH'),
(31, 93, 'WEST'),
(73, 87, 'WEST'),
(37, 23, 'SOUTH'),
]
cohort = Cohort()
cohort.add_bots(5)
message = cohort.create_message()
assert len(message) == 5
for index, msg in enumerate(message):
assert msg['verb'] == 'add_bot'
assert msg['x'] == bot_values[index][0]
assert msg['y'] == bot_values[index][1]
assert msg['direction'] == bot_values[index][2]
assert cohort._bots_to_add == 0
This fails, of course. I expect it’ll say that 5 is not equal to 10.
Expected :5
Actual :10
Perfect.
Now to make the test make the correct call to Cohort … and then have Cohort call us back.
def test_adding_bots(self):
def callback(i, n):
return bot_values[i]
bot_values = [
(5, 10, 'EAST'),
(10, 17, 'NORTH'),
(31, 93, 'WEST'),
(73, 87, 'WEST'),
(37, 23, 'SOUTH'),
]
cohort = Cohort()
cohort.add_bots(5, callback)
message = cohort.create_message()
assert len(message) == 5
for index, msg in enumerate(message):
assert msg['verb'] == 'add_bot'
assert msg['x'] == bot_values[index][0]
assert msg['y'] == bot_values[index][1]
assert msg['direction'] == bot_values[index][2]
assert cohort._bots_to_add == 0
This fails because add_bots
is not yet interested in a callback.
> cohort.add_bots(5, callback)
E TypeError: Cohort.add_bots() takes 2 positional arguments but 3 were given
Let’s get right on that.
class Cohort:
def __init__(self, bot=None):
self.bots = {}
self._callback = None
if bot:
self.bots[bot.id] = bot
self._bots_to_add = 0
def add_bots(self, number, callback: Callable):
self._bots_to_add += number
self._callback = callback
def create_message(self):
message = []
self.add_desired_bots(message)
self.get_existing_bot_actions(message)
return message
def add_desired_bots(self, message):
for i in range(self._bots_to_add):
x, y, d = self._callback(i, self._callback)
action = {'entity': 0,
'verb': 'add_bot',
'x': x,
'y': y,
'direction': d
}
message.append(action)
self._bots_to_add = 0
self._callback = None
I’ll have you know that I just typed that in and it worked, except that I had typed 'y': x
at first, so it failed and then I changed the x to y and it passed. That’s how you do that.
The game will not work now, however, so let’s fix it:
if __name__ == "__main__":
world = World(40, 40)
build_random_blocks()
game = Game(world)
connection = DirectConnection(world)
cohort = Cohort()
cohort.add_bots(20, lambda i,n: (10, 20, 'EAST'))
game.on_execute()
I think that’s correct. Run the game to see. Yes, works as intended.
I’d like to have the callable’s exact form specified in add_bots
. I’ll have to look up how you do that. After a lot of fumbling, I have this, which at least compiles:
def add_bots(self, number, callback: Callable[[int, int], tuple[int, int, str]]):
self._bots_to_add += number
self._callback = callback
Game is good, tests are green. Commit: Cohort.add_bots now takes number of bots and a callback to define x, y and direction.
OK, this works. Can we call it “right”? I think we can call it “more nearly right”, but we should have some concerns. The user’s code won’t compile if they don’t send us something as the second parm to add_bots
. We don’t check to see if it is callable. I’m not sure if we even can check to see if it has the right type. We can surely check to be sure it is callable. This is client-side code, so if it crashes, some student Bot pilot somewhere has a bug in their code. What might the student Bot pilot want to do in his case? Let’s make some mistakes, in some tests, and see what eventuates.
I type this, planning not to provide enough values:
def test_add_error_wrong_return(self):
cohort = Cohort()
cohort.add_bots(5, lambda i, n:( 5, 6))
message = cohort.create_message()
And PyCharm, gotta love these guys, gives me this:
The description of this error, via PyCharm, is this:
Expected type ‘(int, int) -> tuple[int, int, str]’, got ‘(i: Any, n: Any) -> tuple[int, int]’ instead
Inspection info:
Reports type errors in function call expressions, targets, and return values. In a dynamically typed language, this is possible in a limited number of cases.
Types of function parameters can be specified in docstrings or in Python 3 function annotations.
Example:
def foo() -> int:
return "abc" # Expected int, got str
That’s simply outstanding. Well done PyCharm!
The code does compile, and the test fails, saying
def add_desired_bots(self, message):
for i in range(self._bots_to_add):
> x, y, d = self._callback(i, self._callback)
E ValueError: not enough values to unpack (expected 3, got 2)
So we can decorate the test:
def test_add_error_wrong_return(self):
cohort = Cohort()
cohort.add_bots(5, lambda i, n:( 5, 6))
with pytest.raises(ValueError):
message = cohort.create_message()
I think we’ll assume that our client programmers have access to PyCharm and are therefore well protected by its messages, our declaration of the function type, and the associated test.
Commit: added test demonstrating incorrect callback to add_bots.
I think we’ve accomplished what we set out to do. Let’s sum up.
Summary
Yesterday, we left the code working, but not “right”. Today, it’s more nearly right, including a simple yet general way for a client using our Cohort to create bots with whatever coordinates and directions are needed. And we have a nice type signature defined, enabling PyCharm to help us get our lambdas in order.
Is it “right” now? Well, maybe not quite. Let’s notice something about Cohort:
class Cohort:
def __init__(self, bot=None):
self.bots = {}
self._callback = None
if bot:
self.bots[bot.id] = bot
self._bots_to_add = 0
...
def add_desired_bots(self, message):
for i in range(self._bots_to_add):
x, y, d = self._callback(i, self._callback)
action = {'entity': 0,
'verb': 'add_bot',
'x': x,
'y': y,
'direction': d
}
message.append(action)
self._bots_to_add = 0
self._callback = None
In both __init__
and add_desired_bots
, we initialize the number of bots to add, and the callback. We could make that a bit more orderly, a bit more obvious, perhaps like this:
class Cohort:
def __init__(self, bot=None):
self.bots = {}
if bot:
self.bots[bot.id] = bot
self.clear_bot_creation()
# noinspection PyAttributeOutsideInit
def clear_bot_creation(self):
self._bots_to_add = 0
self._callback = None
...
def add_desired_bots(self, message):
for i in range(self._bots_to_add):
x, y, d = self._callback(i, self._callback)
action = {'entity': 0,
'verb': 'add_bot',
'x': x,
'y': y,
'direction': d
}
message.append(action)
self.clear_bot_creation()
Maybe better. I’m still troubled by the None
. I might be a bit troubled by the two-phase character of bot adding.
What do you mean by two-phase?
Yes. Well, I mean that to get some bots added, we call add_bots
, but then subsequently we have to call create_message
. I did want to allow for adding bots as we go, though we have no real need for it. In the real client-server situation, I think we’ll be repeatedly calling create_message
when it’s time to take our turn, and create_message
just processes existing bots, so that was the best I could see at the time. There might be a better way, but at this writing, I still do not see it.
So … it’s more nearly right but perhaps not yet right-right. If and when we have better ideas, we’ll put them into the code.
Commit: new clear_bot_creation
method.
I think we’ve done good work here and left the code much better than it was. And that’s what we set out to do every time, and I think we succeed often than not. Definitely succeeded today. See you next time!