home · contact · privacy
Lots of refactoring.
[plomrogue2-experiments] / client.py
1 #!/usr/bin/env python3
2 import urwid
3 import plom_socket_io
4 import socket
5 import threading
6 from functools import partial
7 import unittest
8
9
10 class ArgumentError(Exception):
11     pass
12
13
14 class ParseError(Exception):
15     pass
16
17
18 class Parser:
19
20     def __init__(self, game):
21         self.game = game
22
23     def tokenize(self, msg):
24         """Parse msg string into tokens.
25
26         Separates by ' ' and '\n', but allows whitespace in tokens quoted by
27         '"', and allows escaping within quoted tokens by a prefixed backslash.
28         """
29         tokens = []
30         token = ''
31         quoted = False
32         escaped = False
33         for c in msg:
34             if quoted:
35                 if escaped:
36                     token += c
37                     escaped = False
38                 elif c == '\\':
39                     escaped = True
40                 elif c == '"':
41                     quoted = False
42                 else:
43                     token += c
44             elif c == '"':
45                 quoted = True
46             elif c in {' ', '\n'}:
47                 if len(token) > 0:
48                     tokens += [token]
49                     token = ''
50             else:
51                 token += c
52         if len(token) > 0:
53             tokens += [token]
54         return tokens
55
56     def parse(self, msg):
57         """Parse msg as call to self.game method, return method with arguments.
58
59         Respects method signatures defined in methods' .argtypes attributes.
60         """
61         tokens = self.tokenize(msg)
62         if len(tokens) == 0:
63             return None
64         method_candidate = 'cmd_' + tokens[0]
65         if not hasattr(self.game, method_candidate):
66             return None
67         method = getattr(self.game, method_candidate)
68         if len(tokens) == 1:
69             if not hasattr(method, 'argtypes'):
70                 return method
71             else:
72                 raise ParseError('Command expects argument(s).')
73         args_candidates = tokens[1:]
74         if not hasattr(method, 'argtypes'):
75             raise ParseError('Command expects no argument(s).')
76         args, kwargs = self.argsparse(method.argtypes, args_candidates)
77         return partial(method, *args, **kwargs)
78
79     def parse_yx_tuple(self, yx_string):
80         """Parse yx_string as yx_tuple:nonneg argtype, return result."""
81
82         def get_axis_position_from_argument(axis, token):
83             if len(token) < 3 or token[:2] != axis + ':' or \
84                     not token[2:].isdigit():
85                 raise ParseError('Non-int arg for ' + axis + ' position.')
86             n = int(token[2:])
87             if n < 1:
88                 raise ParseError('Arg for ' + axis + ' position < 1.')
89             return n
90
91         tokens = yx_string.split(',')
92         if len(tokens) != 2:
93             raise ParseError('Wrong number of yx-tuple arguments.')
94         y = get_axis_position_from_argument('Y', tokens[0])
95         x = get_axis_position_from_argument('X', tokens[1])
96         return (y, x)
97
98     def argsparse(self, signature, args_tokens):
99         """Parse into / return args_tokens as args/kwargs defined by signature.
100
101         Expects signature to be a ' '-delimited sequence of any of the strings
102         'int:nonneg', 'yx_tuple:nonneg', 'string', defining the respective
103         argument types.
104         """
105         tmpl_tokens = signature.split()
106         if len(tmpl_tokens) != len(args_tokens):
107             raise ParseError('Number of arguments (' + str(len(args_tokens)) +
108                              ') not expected number (' + str(len(tmpl_tokens))
109                              + ').')
110         args = []
111         for i in range(len(tmpl_tokens)):
112             tmpl = tmpl_tokens[i]
113             arg = args_tokens[i]
114             if tmpl == 'int:nonneg':
115                 if not arg.isdigit():
116                     raise ParseError('Argument must be non-negative integer.')
117                 args += [int(arg)]
118             elif tmpl == 'yx_tuple:nonneg':
119                 args += [self.parse_yx_tuple(arg)]
120             elif tmpl == 'string':
121                 args += [arg]
122             else:
123                 raise ParseError('Unknown argument type.')
124         return args, {}
125
126
127 class Game:
128     turn = 0
129     log_text = ''
130     map_size = (5, 5)
131     terrain_map = ('?'*5+'\n')*4+'?'*5
132     things = []
133
134     class Thing:
135         def __init__(self, position, symbol):
136             self.position = position
137             self.symbol = symbol
138
139     def log(self, msg):
140         """Prefix msg plus newline to self.log_text."""
141         self.log_text = msg + '\n' + self.log_text
142
143     def cmd_THING(self, type_, yx):
144         """Add to self.things at .position yx with .symbol defined by type_."""
145         symbol = '?'
146         if type_ == 'TYPE:human':
147             symbol = '@'
148         elif type_ == 'TYPE:monster':
149             symbol = 'm'
150         self.things += [self.Thing(yx, symbol)]
151     cmd_THING.argtypes = 'string yx_tuple:nonneg'
152
153     def cmd_MAP_SIZE(self, yx):
154         """Set self.map_size to yx, redraw self.terrain_map as '?' cells."""
155         y, x = yx
156         self.map_size = (y, x)
157         self.terrain_map = ''
158         for y in range(self.map_size[0]):
159             self.terrain_map += '?' * self.map_size[1] + '\n'
160         self.terrain_map = self.terrain_map[:-1]
161     cmd_MAP_SIZE.argtypes = 'yx_tuple:nonneg'
162
163     def cmd_TURN_FINISHED(self, n):
164         """Do nothing. (This may be extended later.)"""
165         pass
166     cmd_TURN_FINISHED.argtypes = 'int:nonneg'
167
168     def cmd_NEW_TURN(self, n):
169         """Set self.turn to n, empty self.things."""
170         self.turn = n
171         self.things = []
172     cmd_NEW_TURN.argtypes = 'int:nonneg'
173
174     def cmd_TERRAIN(self, terrain_map):
175         """Reset self.terrain_map from terrain_map."""
176         lines = terrain_map.split('\n')
177         if len(lines) != self.map_size[0]:
178             raise ArgumentError('wrong map height')
179         for line in lines:
180             if len(line) != self.map_size[1]:
181                 raise ArgumentError('wrong map width')
182         self.terrain_map = terrain_map
183     cmd_TERRAIN.argtypes = 'string'
184
185
186 class WidgetManager:
187
188     def __init__(self, socket, game):
189         """Set up all urwid widgets we want on the screen."""
190         self.game = game
191         edit_widget = self.EditToSocketWidget(socket, 'SEND: ')
192         self.map_widget = urwid.Text('', wrap='clip')
193         self.turn_widget = urwid.Text('')
194         self.log_widget = urwid.Text('')
195         map_box = urwid.Padding(self.map_widget, width=50)
196         widget_pile = urwid.Pile([edit_widget, map_box, self.turn_widget,
197                                   self.log_widget])
198         self.top = urwid.Filler(widget_pile, valign='top')
199
200     def draw_map(self):
201         """Draw map view from .game.terrain_map, .game.things."""
202         whole_map = []
203         for c in self.game.terrain_map:
204             whole_map += [c]
205         for t in self.game.things:
206             pos_i = t.position[0] * (self.game.map_size[1] + 1) + t.position[1]
207             whole_map[pos_i] = t.symbol
208         return ''.join(whole_map)
209
210     def update(self):
211         """Redraw all non-edit widgets."""
212         self.turn_widget.set_text('TURN: ' + str(self.game.turn))
213         self.log_widget.set_text(self.game.log_text)
214         self.map_widget.set_text(self.draw_map())
215
216     class EditToSocketWidget(urwid.Edit):
217         """Extends urwid.Edit with socket to send input on 'enter' to."""
218
219         def __init__(self, socket, *args, **kwargs):
220             super().__init__(*args, **kwargs)
221             self.socket = socket
222
223         def keypress(self, size, key):
224             """Extend super(): on Enter, send .edit_text, and empty it."""
225             if key != 'enter':
226                 return super().keypress(size, key)
227             plom_socket_io.send(self.socket, self.edit_text)
228             self.edit_text = ''
229
230
231 class PlomRogueClient:
232
233     def __init__(self, game, socket):
234         """Build client urwid interface around socket communication.
235
236         Sets up all widgets for writing to the socket and representing data
237         from it. Sending via a WidgetManager.EditToSocket widget is
238         straightforward; polling the socket for input from the server in
239         parallel to the urwid main loop not so much:
240
241         The urwid developers warn against sharing urwid resources among
242         threads, so having a socket polling thread for writing to an urwid
243         widget while other widgets are handled in other threads would be
244         dangerous. Urwid developers recommend using urwid's watch_pipe
245         mechanism instead: using a pipe from non-urwid threads into a single
246         urwid thread. We use self.recv_loop_thread to poll the socket, therein
247         write socket.recv output to an object that is then linked to by
248         self.server_output (which is known to the urwid thread), then use the
249         pipe to urwid to trigger it pulling new data from self.server_output to
250         handle via self.handle_input. (We *could* pipe socket.recv output
251         directly, but then we get complicated buffering situations here as well
252         as in the urwid code that receives the pipe output. It's easier to just
253         tell the urwid code where it finds full new server messages to handle.)
254         """
255         self.game = game
256         self.parser = Parser(self.game)
257         self.socket = socket
258         self.widget_manager = WidgetManager(self.socket, self.game)
259         self.server_output = []
260         self.urwid_loop = urwid.MainLoop(self.widget_manager.top)
261         self.urwid_pipe_write_fd = self.urwid_loop.watch_pipe(self.
262                                                               handle_input)
263         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
264
265     def handle_input(self, trigger):
266         """On input from recv_loop thread, parse and enact commands.
267
268         Serves as a receiver to urwid's watch_pipe mechanism, with trigger the
269         data that a pipe defined by watch_pipe delivers. To avoid buffering
270         trouble, we don't care for that data beyond the fact that its receival
271         triggers this function: The sender is to write the data it wants to
272         deliver into the container referenced by self.server_output, and just
273         pipe the trigger to inform us about this.
274
275         If the message delivered is 'BYE', quits Urwid. Otherwise tries to
276         parse it as a command, and enact it. In all cases but the 'BYE', calls
277         self.widget_manager.update.
278         """
279         msg = self.server_output[0]
280         if msg == 'BYE':
281             raise urwid.ExitMainLoop()
282         try:
283             command = self.parser.parse(msg)
284             if command is None:
285                 self.game.log('UNHANDLED INPUT: ' + msg)
286             else:
287                 command()
288         except (ArgumentError, ParseError) as e:
289             self.game.log('ARGUMENT ERROR: ' + msg + '\n' + str(e))
290         self.widget_manager.update()
291         del self.server_output[0]
292
293     def recv_loop(self):
294         """Loop to receive messages from socket, deliver them to urwid thread.
295
296         Waits for self.server_output to become empty (this signals that the
297         input handler is finished / ready to receive new input), then writes
298         finished message from socket to self.server_output, then sends a single
299         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
300         """
301         import os
302         for msg in plom_socket_io.recv(self.socket):
303             while len(self.server_output) > 0:  # Wait until self.server_output
304                 pass                            # is emptied by input handler.
305             self.server_output += [msg]
306             os.write(self.urwid_pipe_write_fd, b' ')
307
308     def run(self):
309         """Run in parallel urwid_loop and recv_loop threads."""
310         self.recv_loop_thread.start()
311         self.urwid_loop.run()
312         self.recv_loop_thread.join()
313
314
315 class TestParser(unittest.TestCase):
316
317     def test_tokenizer(self):
318         p = Parser(Game())
319         self.assertEqual(p.tokenize(''), [])
320         self.assertEqual(p.tokenize(' '), [])
321         self.assertEqual(p.tokenize('abc'), ['abc'])
322         self.assertEqual(p.tokenize('a b\nc  "d"'), ['a', 'b', 'c', 'd'])
323         self.assertEqual(p.tokenize('a "b\nc d"'), ['a', 'b\nc d'])
324         self.assertEqual(p.tokenize('a"b"c'), ['abc'])
325         self.assertEqual(p.tokenize('a\\b'), ['a\\b'])
326         self.assertEqual(p.tokenize('"a\\b"'), ['ab'])
327         self.assertEqual(p.tokenize('a"b'), ['ab'])
328         self.assertEqual(p.tokenize('a"\\"b'), ['a"b'])
329
330     def test_unhandled(self):
331         p = Parser(Game())
332         self.assertEqual(p.parse(''), None)
333         self.assertEqual(p.parse(' '), None)
334         self.assertEqual(p.parse('x'), None)
335
336     def test_argsparse(self):
337         p = Parser(Game())
338         assertErr = partial(self.assertRaises, ParseError, p.argsparse)
339         assertErr('', ['foo'])
340         assertErr('string', [])
341         assertErr('string string', ['foo'])
342         self.assertEqual(p.argsparse('string', ('foo',)),
343                          (['foo'], {}))
344         self.assertEqual(p.argsparse('string string', ('foo', 'bar')),
345                          (['foo', 'bar'], {}))
346         assertErr('int:nonneg', [''])
347         assertErr('int:nonneg', ['x'])
348         assertErr('int:nonneg', ['-1'])
349         assertErr('int:nonneg', ['0.1'])
350         self.assertEqual(p.argsparse('int:nonneg', ('0',)),
351                          ([0], {}))
352         assertErr('yx_tuple:nonneg', ['x'])
353         assertErr('yx_tuple:nonneg', ['Y:0,X:1'])
354         assertErr('yx_tuple:nonneg', ['Y:1,X:0'])
355         assertErr('yx_tuple:nonneg', ['Y:1.1,X:1'])
356         assertErr('yx_tuple:nonneg', ['Y:1,X:1.1'])
357         self.assertEqual(p.argsparse('yx_tuple:nonneg', ('Y:1,X:2',)),
358                          ([(1, 2)], {}))
359
360
361 if __name__ == '__main__':
362     game = Game()
363     s = socket.create_connection(('127.0.0.1', 5000))
364     p = PlomRogueClient(game, s)
365     p.run()
366     s.close()