Just Fiddling
Hello, loves!
I was fiddling with the Denizen. I have a small need and a small idea. Small steps to nice outcome. Tout le monde déteste l’IA.
Here’s Denizen, a bit changed from when you last saw it, I think. I changed it to use Sayers throughout:
class Denizen:
def __init__(self, *, name,
initial_sayings=None,
gift=None):
self.name = name
if initial_sayings is None:
initial_sayings = ['Hmm...']
self.initial_sayings = NameSayer.cycle(self.name, initial_sayings)
gift_sentences = [
'Oh, thank you!',
'Here\'s something you may need.',
]
self.gift_sayings = NameSayer.sequence(self.name, gift_sentences)
satisfied_sentences = [
'I\'m just a happy little bee!',
'Hmmm, hmmm, just buzzin along.',
'Nothing to see here, just a bee'
]
self.satisfied_sayings = NameSayer.cycle(self.name, satisfied_sentences)
self.gift = gift
self.say = 0
self.gift = gift
from content import ContentFactory
self.gift = ContentFactory().receivable(name='delicious honeycomb',
resource='honeycomb.png',
scale=1)
self.state = self.seeking
def interact(self, interactor):
self.state = self.state(interactor)
return False
def seeking(self, interactor):
if interactor.has('a flower'):
if self.gift:
interactor.announce(self.gift_sayings.saying)
interactor.announce(self.gift_sayings.saying)
interactor.receive_content(self.gift)
return self.satisfied
else:
interactor.announce(self.initial_sayings.saying)
return self.seeking
def satisfied(self, interactor):
interactor.announce(self.satisfied_sayings.saying)
return self.satisfied
In the fullness of time, we’ll be passing in all three of those sayings. At least for now, I think we’ll keep the behavior we see there, where when unsatisfied, we cycle the initial sayings, upon receiving the desired item (the flower), we’ll say all the things in the gift sayings, and then we’ll cycle the satisfied sayings.
As things stand, the gift-receiving code needs to know how many messages there are in the gift sayings. It is presently assuming two. But there’s no reason to imagine that every Denizen will have exactly two things to say at this point. So the question is what we might do about that. Options that come to mind include:
- Provide a
lenmethod ad use it in the gift code to loop; - Allow messages to be more than one line long and unwind them somehow;
- Have a new kind of Sayer that returns a signal (None?) when its sayings are consumed;
- Other ideas as yet not thought of …
Something about this problem and the Sayer classes makes me think of generators in Python, objects that yield a result rather than just return it, allowing looping over them, and sometimes making things a bit nicer.
I think I”d like to experiment with that idea a bit. I’ll write some tests.
Took me a bit of fumbling. Now I have two new tests:
def test_sequence_for(self):
sayings = ['a', 'b', 'c']
sayer = SequenceSayer(sayings)
i = 0
for s in sayer:
assert s == sayings[i]
i += 1
def test_sequence_next(self):
sayings = ['a', 'b', 'c']
sayer = SequenceSayer(sayings)
assert next(sayer) == 'a'
assert next(sayer) == 'b'
assert next(sayer) == 'c'
try:
next(sayer)
except StopIteration:
pass
else:
assert False
These pass, with this code:
class SequenceSayer(Sayer):
def __init__(self, messages):
self.messages = messages
self.index = 0
@property
def saying(self):
message = self.messages[self.index]
self.index = min(self.index + 1, len(self.messages)-1)
return message
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.messages):
raise StopIteration
m = self.messages[self.index]
self.index += 1
return m
Could we change the saying method to use next? Note that the Sequence sayer is really supposed to repeat the last item. Let me change my test first.
def test_sequence_next(self):
sayings = ['a', 'b', 'c']
sayer = SequenceSayer(sayings)
assert next(sayer) == 'a'
assert next(sayer) == 'b'
assert next(sayer) == 'c'
assert next(sayer) == 'c'
That’s failing now but …
def __next__(self):
m = self.messages[self.index]
self.index = min(self.index + 1, len(self.messages)-1)
return m
I need to change my for test, lest it run off the end of its test table:
def test_sequence_for(self):
sayings = ['a', 'b', 'c']
sayer = SequenceSayer(sayings)
for i, s in enumerate(sayer):
if i >= 3:
break
assert s == sayings[i]
As for next in saying, this works:
@property
def saying(self):
return next(self)
Reflection
We have, so far, three kinds of sayers, sequence, cycle, and random. Our intention, so far, is that these area all supposed to produce strings forever, with cycle cycling, sequence repeating the last element, and random, well, random.
You really wouldn’t want to call for on any of these, unless you weren’t in a hurry for your next move. I do think I like saying next(seq) over seq.saying. d
Is this worth doing? Is it just too arcane, too clever? I’m not sure. I have a feeling that we’ll find something nice if we follow this path. Let’s make all the Sayers iterable, just copying most of the code into each one:
class CycleSayer(Sayer):
def __init__(self, messages):
self.messages = messages
self.index = 0
@property
def saying(self):
return next(self)
def __iter__(self):
return self
def __next__(self):
m = self.messages[self.index]
self.index = (self.index + 1)%len(self.messages)
return m
Green, by the way. All this time.
class RandomSayer(Sayer):
def __init__(self, messages):
self.messages = messages
self.length = len(self.messages)
@property
def saying(self):
return next(self)
def __iter__(self):
return self
def __next__(self):
return self.messages[randrange(self.length)]
I think this one will need changing again, if what I have in mind works out.
class NameSayer(Sayer):
@classmethod
def cycle(cls, name, sayings):
return cls(name, CycleSayer(sayings))
@classmethod
def random(cls, name, sayings):
return cls(name, RandomSayer(sayings))
@classmethod
def sequence(cls, name, sayings):
return cls(name, SequenceSayer(sayings))
def __init__(self, name, sequence):
self.name = name
self.sequence = sequence
@property
def saying(self):
return next(self)
def __iter__(self):
return self
def __next__(self):
return f'{self.name}: "{next(self.sequence)}"'
Now I think we can replace all the .saying references with next. With that done, our three bottom-level classes are quite similar, with just one difference, the index handling:
class SequenceSayer(Sayer):
def __init__(self, messages):
self.messages = messages
self.index = 0
def __iter__(self):
return self
def __next__(self):
m = self.messages[self.index]
self.index = min(self.index + 1, len(self.messages)-1)
return m
class CycleSayer(Sayer):
def __init__(self, messages):
self.messages = messages
self.index = 0
def __iter__(self):
return self
def __next__(self):
m = self.messages[self.index]
self.index = (self.index + 1)%len(self.messages)
return m
class RandomSayer(Sayer):
def __init__(self, messages):
self.messages = messages
self.length = len(self.messages)
def __iter__(self):
return self
def __next__(self):
return self.messages[randrange(self.length)]
Let’s make RandomSayer look more like the others.
class RandomSayer(Sayer):
def __init__(self, messages):
self.messages = messages
self.index = 0
def __iter__(self):
return self
def __next__(self):
m = self.messages[self.index]
self.index = randrange(len(self.messages))
return m
Now CycleSayer, RandomSayer, and SequenceSayer are identical except for the line that updates their index. So we should be able to collapse them into a single class with a parameter to handle the index update. Let’s push the functionality up into the abstract class, Sayer, with an eye to winding up with that as the sole concrete class. We’ll remove the ABC inheritance right away.
class Sayer:
def __init__(self, messages, indexing=''):
self.indexing = indexing
self.messages = messages
self.index = 0
def __iter__(self):
return self
def __next__(self):
m = self.messages[self.index]
self.update_index()
return m
def update_index(self):
match self.indexing:
case 'sequence':
self.index = min(self.index + 1, len(self.messages)-1)
case 'cycle':
self.index = (self.index + 1) % len(self.messages)
case 'random':
self.index = randrange(len(self.messages))
case _:
pass
Now, if I’m not mistaken, and I might well be mistaken, we should be able to change the subclasses. Like this:
class SequenceSayer(Sayer):
def __init__(self, messages):
super().__init__(messages, 'sequence')
NOthing more, just that. No other methods. And pretty soon we’ll get rid of the classes altogether. Most irritating part of that will be editing the tests. The super trick keeps the tests running.
class SequenceSayer(Sayer):
def __init__(self, messages):
super().__init__(messages, 'sequence')
class CycleSayer(Sayer):
def __init__(self, messages):
super().__init__(messages, 'cycle')
class RandomSayer(Sayer):
def __init__(self, messages):
super().__init__(messages, 'random')
Let’s commit, this is going to be just fine. Commit: refactoring down to one Sayer class.
Now in NameSayer, we create all our other Sayers:
class NameSayer(Sayer):
@classmethod
def cycle(cls, name, sayings):
return cls(name, CycleSayer(sayings))
@classmethod
def random(cls, name, sayings):
return cls(name, RandomSayer(sayings))
@classmethod
def sequence(cls, name, sayings):
return cls(name, SequenceSayer(sayings))
And we change those methods:
class NameSayer:
@classmethod
def cycle(cls, name, sayings):
return cls(name, Sayer(sayings, 'cycle'))
@classmethod
def random(cls, name, sayings):
return cls(name, Sayer(sayings, 'random'))
@classmethod
def sequence(cls, name, sayings):
return cls(name, Sayer(sayings, 'sequence'))
Commit.
Now find the users of the subclasses and replace them, from this:
def test_say_sequence(self):
seq = SequenceSayer(['a', 'b', 'c'])
assert next(seq) == 'a'
assert next(seq) == 'b'
assert next(seq) == 'c'
assert next(seq) == 'c'
To this:
def test_say_sequence(self):
seq = Sayer(['a', 'b', 'c'], 'sequence')
assert next(seq) == 'a'
assert next(seq) == 'b'
assert next(seq) == 'c'
assert next(seq) == 'c'
Green. Commit. Same change for CycleSayer and RandomSayer. Green. Remove the classes. Green.
Commit: combined SequenceSayer, CycleSayer, RandomSayer into single Sayer class. NameSayer still separate and should likely remain so.
Reflection
IO think we might let this ride, at least for the morning. I’m about two hours in and in a series of small changes, have reduced three classes with inheritance to one class. I do not like the match-case for handling the indexing, and think we could do better.
What if we used a Lambda? Maybe like this:
class Sayer:
def __init__(self, messages, indexing=''):
self.update_index = self.define_indexing(indexing)
self.messages = messages
self.index = 0
def __iter__(self):
return self
def __next__(self):
m = self.messages[self.index]
self.index = self.update_index(self.index)
return m
def define_indexing(self, indexing):
indexing_types = dict(
cycle=lambda index: (self.index + 1) % len(self.messages),
random=lambda index: randrange(len(self.messages)),
sequence=lambda index: min(self.index + 1, len(self.messages)-1),
)
return indexing_types.get(indexing, lambda index: index)
Now we choose the function we want for updating and stuff it into the update_index to be executed. We’ve reduced a match-case execution on every next down to a single dictionary access when the object is created.
Commit: pluggable behavior for index updating.
I think we might want to add class methods to Sayer, to hide how we define our cases. Maybe next time, we’ve done enough and I am ready to rest.
Summary
By defining the Sayers as iterable, we’ve allowed ourselves to say next(sayer) which is more familiar than sayer.saying. We’ve reduced three classes plus an abstract superclass down to one class with pluggable behavior. I think this is better.
It is a bit deeper in the bag of tricks, but since we believe in covering collections with classes anyway, this is a good thing to know how to do and to be familiar with.
I think I like it. See you next time!