home · contact · privacy
Use math.isclose() to fix FOV bug instead of expensive Fraction.
[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, range_):
71         """Parse yx_string as yx_tuple:nonneg argtype, return result.
72
73         The range_ argument may be 'nonneg' (non-negative, including 0)
74         or 'pos' (positive, excluding 0).
75         """
76
77         def get_axis_position_from_argument(axis, token):
78             if len(token) < 3 or token[:2] != axis + ':' or \
79                     not token[2:].isdigit():
80                 raise ArgError('Non-int arg for ' + axis + ' position.')
81             n = int(token[2:])
82             if n < 1 and range_ == 'pos':
83                 raise ArgError('Arg for ' + axis + ' position < 1.')
84             elif n < 0 and range_ == 'nonneg':
85                 raise ArgError('Arg for ' + axis + ' position < 0.')
86             return n
87
88         tokens = yx_string.split(',')
89         if len(tokens) != 2:
90             raise ArgError('Wrong number of yx-tuple arguments.')
91         y = get_axis_position_from_argument('Y', tokens[0])
92         x = get_axis_position_from_argument('X', tokens[1])
93         return (y, x)
94
95     def argsparse(self, signature, args_tokens):
96         """Parse into / return args_tokens as args/kwargs defined by signature.
97
98         Expects signature to be a ' '-delimited sequence of any of the strings
99         'int:nonneg', 'yx_tuple:nonneg', 'yx_tuple:pos', 'string',
100         'seq:int:nonneg', defining the respective argument types.
101         """
102         tmpl_tokens = signature.split()
103         if len(tmpl_tokens) != len(args_tokens):
104             raise ArgError('Number of arguments (' + str(len(args_tokens)) +
105                            ') not expected number (' + str(len(tmpl_tokens))
106                            + ').')
107         args = []
108         for i in range(len(tmpl_tokens)):
109             tmpl = tmpl_tokens[i]
110             arg = args_tokens[i]
111             if tmpl == 'int:nonneg':
112                 if not arg.isdigit():
113                     raise ArgError('Argument must be non-negative integer.')
114                 args += [int(arg)]
115             elif tmpl == 'yx_tuple:nonneg':
116                 args += [self.parse_yx_tuple(arg, 'nonneg')]
117             elif tmpl == 'yx_tuple:pos':
118                 args += [self.parse_yx_tuple(arg, 'pos')]
119             elif tmpl == 'string':
120                 args += [arg]
121             elif tmpl == 'seq:int:nonneg':
122                 sub_tokens = arg.split(',')
123                 if len(sub_tokens) < 1:
124                     raise ArgError('Argument must be non-empty sequence.')
125                 seq = []
126                 for tok in sub_tokens:
127                     if not tok.isdigit():
128                         raise ArgError('Argument sequence must only contain '
129                                        'non-negative integers.')
130                     seq += [int(tok)]
131                 args += [seq]
132             else:
133                 raise ArgError('Unknown argument type.')
134         return args, {}
135
136
137 class TestParser(unittest.TestCase):
138
139     def test_tokenizer(self):
140         p = Parser()
141         self.assertEqual(p.tokenize(''), [])
142         self.assertEqual(p.tokenize(' '), [])
143         self.assertEqual(p.tokenize('abc'), ['abc'])
144         self.assertEqual(p.tokenize('a b\nc  "d"'), ['a', 'b', 'c', 'd'])
145         self.assertEqual(p.tokenize('a "b\nc d"'), ['a', 'b\nc d'])
146         self.assertEqual(p.tokenize('a"b"c'), ['abc'])
147         self.assertEqual(p.tokenize('a\\b'), ['a\\b'])
148         self.assertEqual(p.tokenize('"a\\b"'), ['ab'])
149         self.assertEqual(p.tokenize('a"b'), ['ab'])
150         self.assertEqual(p.tokenize('a"\\"b'), ['a"b'])
151
152     def test_unhandled(self):
153         p = Parser()
154         self.assertEqual(p.parse(''), None)
155         self.assertEqual(p.parse(' '), None)
156         self.assertEqual(p.parse('x'), None)
157
158     def test_argsparse(self):
159         from functools import partial
160         p = Parser()
161         assertErr = partial(self.assertRaises, ArgError, p.argsparse)
162         assertErr('', ['foo'])
163         assertErr('string', [])
164         assertErr('string string', ['foo'])
165         self.assertEqual(p.argsparse('string', ('foo',)),
166                          (['foo'], {}))
167         self.assertEqual(p.argsparse('string string', ('foo', 'bar')),
168                          (['foo', 'bar'], {}))
169         assertErr('int:nonneg', [''])
170         assertErr('int:nonneg', ['x'])
171         assertErr('int:nonneg', ['-1'])
172         assertErr('int:nonneg', ['0.1'])
173         self.assertEqual(p.argsparse('int:nonneg', ('0',)),
174                          ([0], {}))
175         assertErr('yx_tuple:nonneg', ['x'])
176         assertErr('yx_tuple:nonneg', ['Y:0,X:-1'])
177         assertErr('yx_tuple:nonneg', ['Y:-1,X:0'])
178         assertErr('yx_tuple:nonneg', ['Y:1.1,X:1'])
179         assertErr('yx_tuple:nonneg', ['Y:1,X:1.1'])
180         self.assertEqual(p.argsparse('yx_tuple:nonneg', ('Y:1,X:2',)),
181                          ([(1, 2)], {}))
182         assertErr('yx_tuple:pos', ['Y:0,X:1'])
183         assertErr('yx_tuple:pos', ['Y:1,X:0'])
184         assertErr('seq:int:nonneg', [''])
185         assertErr('seq:int:nonneg', [','])
186         assertErr('seq:int:nonneg', ['a'])
187         assertErr('seq:int:nonneg', ['a,1'])
188         assertErr('seq:int:nonneg', [',1'])
189         assertErr('seq:int:nonneg', ['1,'])
190         self.assertEqual(p.argsparse('seq:int:nonneg', ('1,2,3',)),
191                          ([[1, 2, 3]], {}))