home · contact · privacy
Share parsing code between client and server.
[plomrogue2-experiments] / parser.py
1 import unittest
2 from functools import partial
3
4
5 class ArgError(Exception):
6     pass
7
8
9 class Parser:
10
11     def __init__(self, game=None):
12         self.game = game
13
14     def tokenize(self, msg):
15         """Parse msg string into tokens.
16
17         Separates by ' ' and '\n', but allows whitespace in tokens quoted by
18         '"', and allows escaping within quoted tokens by a prefixed backslash.
19         """
20         tokens = []
21         token = ''
22         quoted = False
23         escaped = False
24         for c in msg:
25             if quoted:
26                 if escaped:
27                     token += c
28                     escaped = False
29                 elif c == '\\':
30                     escaped = True
31                 elif c == '"':
32                     quoted = False
33                 else:
34                     token += c
35             elif c == '"':
36                 quoted = True
37             elif c in {' ', '\n'}:
38                 if len(token) > 0:
39                     tokens += [token]
40                     token = ''
41             else:
42                 token += c
43         if len(token) > 0:
44             tokens += [token]
45         return tokens
46
47     def parse(self, msg):
48         """Parse msg as call to self.game method, return method with arguments.
49
50         Respects method signatures defined in methods' .argtypes attributes.
51         """
52         tokens = self.tokenize(msg)
53         if len(tokens) == 0:
54             return None
55         method_candidate = 'cmd_' + tokens[0]
56         if not hasattr(self.game, method_candidate):
57             return None
58         method = getattr(self.game, method_candidate)
59         if len(tokens) == 1:
60             if not hasattr(method, 'argtypes'):
61                 return method
62             else:
63                 raise ArgError('Command expects argument(s).')
64         args_candidates = tokens[1:]
65         if not hasattr(method, 'argtypes'):
66             raise ArgError('Command expects no argument(s).')
67         args, kwargs = self.argsparse(method.argtypes, args_candidates)
68         return partial(method, *args, **kwargs)
69
70     def parse_yx_tuple(self, yx_string):
71         """Parse yx_string as yx_tuple:nonneg argtype, return result."""
72
73         def get_axis_position_from_argument(axis, token):
74             if len(token) < 3 or token[:2] != axis + ':' or \
75                     not token[2:].isdigit():
76                 raise ArgError('Non-int arg for ' + axis + ' position.')
77             n = int(token[2:])
78             if n < 1:
79                 raise ArgError('Arg for ' + axis + ' position < 1.')
80             return n
81
82         tokens = yx_string.split(',')
83         if len(tokens) != 2:
84             raise ArgError('Wrong number of yx-tuple arguments.')
85         y = get_axis_position_from_argument('Y', tokens[0])
86         x = get_axis_position_from_argument('X', tokens[1])
87         return (y, x)
88
89     def argsparse(self, signature, args_tokens):
90         """Parse into / return args_tokens as args/kwargs defined by signature.
91
92         Expects signature to be a ' '-delimited sequence of any of the strings
93         'int:nonneg', 'yx_tuple:nonneg', 'string', 'seq:int:nonneg', defining
94         the respective argument types.
95         """
96         tmpl_tokens = signature.split()
97         if len(tmpl_tokens) != len(args_tokens):
98             raise ArgError('Number of arguments (' + str(len(args_tokens)) +
99                            ') not expected number (' + str(len(tmpl_tokens))
100                            + ').')
101         args = []
102         for i in range(len(tmpl_tokens)):
103             tmpl = tmpl_tokens[i]
104             arg = args_tokens[i]
105             if tmpl == 'int:nonneg':
106                 if not arg.isdigit():
107                     raise ArgError('Argument must be non-negative integer.')
108                 args += [int(arg)]
109             elif tmpl == 'yx_tuple:nonneg':
110                 args += [self.parse_yx_tuple(arg)]
111             elif tmpl == 'string':
112                 args += [arg]
113             elif tmpl == 'seq:int:nonneg':
114                 sub_tokens = arg.split(',')
115                 if len(sub_tokens) < 1:
116                     raise ArgError('Argument must be non-empty sequence.')
117                 seq = []
118                 for tok in sub_tokens:
119                     if not tok.isdigit():
120                         raise ArgError('Argument sequence must only contain '
121                                        'non-negative integers.')
122                     seq += [int(tok)]
123                 args += [seq]
124             else:
125                 raise ArgError('Unknown argument type.')
126         return args, {}
127
128
129 class TestParser(unittest.TestCase):
130
131     def test_tokenizer(self):
132         p = Parser()
133         self.assertEqual(p.tokenize(''), [])
134         self.assertEqual(p.tokenize(' '), [])
135         self.assertEqual(p.tokenize('abc'), ['abc'])
136         self.assertEqual(p.tokenize('a b\nc  "d"'), ['a', 'b', 'c', 'd'])
137         self.assertEqual(p.tokenize('a "b\nc d"'), ['a', 'b\nc d'])
138         self.assertEqual(p.tokenize('a"b"c'), ['abc'])
139         self.assertEqual(p.tokenize('a\\b'), ['a\\b'])
140         self.assertEqual(p.tokenize('"a\\b"'), ['ab'])
141         self.assertEqual(p.tokenize('a"b'), ['ab'])
142         self.assertEqual(p.tokenize('a"\\"b'), ['a"b'])
143
144     def test_unhandled(self):
145         p = Parser()
146         self.assertEqual(p.parse(''), None)
147         self.assertEqual(p.parse(' '), None)
148         self.assertEqual(p.parse('x'), None)
149
150     def test_argsparse(self):
151         from functools import partial
152         p = Parser()
153         assertErr = partial(self.assertRaises, ArgError, p.argsparse)
154         assertErr('', ['foo'])
155         assertErr('string', [])
156         assertErr('string string', ['foo'])
157         self.assertEqual(p.argsparse('string', ('foo',)),
158                          (['foo'], {}))
159         self.assertEqual(p.argsparse('string string', ('foo', 'bar')),
160                          (['foo', 'bar'], {}))
161         assertErr('int:nonneg', [''])
162         assertErr('int:nonneg', ['x'])
163         assertErr('int:nonneg', ['-1'])
164         assertErr('int:nonneg', ['0.1'])
165         self.assertEqual(p.argsparse('int:nonneg', ('0',)),
166                          ([0], {}))
167         assertErr('yx_tuple:nonneg', ['x'])
168         assertErr('yx_tuple:nonneg', ['Y:0,X:1'])
169         assertErr('yx_tuple:nonneg', ['Y:1,X:0'])
170         assertErr('yx_tuple:nonneg', ['Y:1.1,X:1'])
171         assertErr('yx_tuple:nonneg', ['Y:1,X:1.1'])
172         self.assertEqual(p.argsparse('yx_tuple:nonneg', ('Y:1,X:2',)),
173                          ([(1, 2)], {}))
174         assertErr('seq:int:nonneg', [''])
175         assertErr('seq:int:nonneg', [','])
176         assertErr('seq:int:nonneg', ['a'])
177         assertErr('seq:int:nonneg', ['a,1'])
178         assertErr('seq:int:nonneg', [',1'])
179         assertErr('seq:int:nonneg', ['1,'])
180         self.assertEqual(p.argsparse('seq:int:nonneg', ('1,2,3',)),
181                          ([[1, 2, 3]], {}))