blob: fc7d8c7d43da163a939813d71d1bba5d652c5a4e [file] [log] [blame]
Zack Williams28f1e492019-02-01 10:02:56 -07001# Copyright 2017-present Open Networking Foundation
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from __future__ import print_function
16import unittest
17
18import plyxproto.parser as plyxparser
19import plyxproto.model as plyxmodel
20import plyxproto.helpers as plyxhelpers
21
22# Example of creating unittests on PLY code:
23# https://github.com/dabeaz/ply/tree/master/test
24# http://www.dalkescientific.com/writings/NBN/parsing_with_ply.html
25
26
27def get_name(element):
28 '''returns name of element'''
29 return element.name.value.pval
30
31
32def get_field_type(ft):
33 ''' returns field type'''
34
35 # FIXME: these are confusing
36 if isinstance(ft, plyxmodel.FieldType):
37 return ft.name.pval
38
39 elif isinstance(ft, plyxmodel.DotName):
40 return ft.value
41
42 else:
43 print("unknown field type: %s" % ft)
44 raise BaseException
45
46
47def get_field_directives(field):
48 ''' navigate the morass that is fieldDirective '''
49
50 fds = {}
51
52 for fieldd in field.fieldDirective:
53
54 fd_key = ""
55 fd_val = ""
56
57 # FIXME: Do these differences in heirarchy have value? They seem
58 # arbitrary and maybe the parser should handle it instead?
59
60 if hasattr(fieldd, 'name'):
61 fd_key = fieldd.name.value
62 elif hasattr(fieldd.pval.name, 'pval'):
63 fd_key = fieldd.pval.name.pval
64 elif hasattr(fieldd.pval.name.value, 'pval'):
65 fd_key = fieldd.pval.name.value.pval
66 else:
67 print("problem with key in fieldDirective: ", fieldd)
68 raise BaseException
69
70 if type(fieldd) is list:
71 fd_val = [item.pval for item in fieldd.value]
72 elif hasattr(fieldd, 'value'):
73 if type(fieldd.value) is list:
74 fd_val = [item.pval for item in fieldd.value]
75 elif hasattr(fieldd.value, 'pval'):
76 fd_val = fieldd.value.pval
77 elif hasattr(fieldd.value, 'value'):
78 if hasattr(fieldd.value.value, 'pval'):
79 fd_val = fieldd.value.value.pval
80 else:
81 fd_val = fieldd.value.value
82 else:
83 print("problem with value.value in fieldDirective: %s" % fieldd)
84 raise BaseException
85 elif hasattr(fieldd.pval.value, 'pval'):
86 fd_val = fieldd.pval.value.pval
87 elif hasattr(fieldd.pval.value.value, 'pval'):
88 fd_val = fieldd.pval.value.value.pval
89 else:
90 print("problem with value in fieldDirective: ", fieldd)
91 raise BaseException
92
93 fds[fd_key] = fd_val
94
95 return fds
96
97
98def msg_dict(msgdef):
99 '''
100 Given a MessageDefinition object, returns a dict describing it
101 Should probably really be a recursive __dict__ method on that object
102 '''
103
104 fd = {} # FieldDefinition
105 md = {} # sub-ModelDefinition
106 en = {} # EnumDefinitons
107 ed = {} # ExtensionsDirective
108 os = {} # OptionStatement
109
110 assert isinstance(msgdef, plyxmodel.MessageDefinition)
111
112 for field in msgdef.body:
113
114 if isinstance(field, plyxmodel.FieldDefinition):
115
116 fd[field.name.value.pval] = {
117 'id': int(field.fieldId.pval),
118 'modifier': field.field_modifier.pval,
119 'type': get_field_type(field.ftype),
120 'policy': field.policy,
121 'directives': get_field_directives(field),
122 }
123
124 elif isinstance(field, plyxmodel.LinkSpec):
125
126 fd[field.field_def.name.value.pval] = {
127 'id': int(field.field_def.fieldId.pval),
128 'modifier': field.field_def.field_modifier.pval,
129 'type': field.field_def.ftype.value, # Why different (not in a LU as in FieldDefinition)?
130 'link_type': field.link_def.link_type.pval,
131 'link_name': field.link_def.name[0].pval, # Why in a list?
132 'link_src_port': field.link_def.src_port.value.pval, # confusingly named - why include "port" ?
133 'link_dst_port': field.link_def.dst_port.value.pval,
134 'directives': get_field_directives(field.field_def),
135 }
136
137 elif isinstance(field, plyxmodel.MessageDefinition):
138
139 md[field.name.value.pval] = msg_dict(field)
140
141 elif isinstance(field, plyxmodel.EnumDefinition):
142
143 enumopts = {}
144
145 for enumdef in field.body:
146 enumopts[int(enumdef.fieldId.pval)] = get_name(enumdef)
147
148 en[field.name.value.pval] = enumopts
149
150 elif isinstance(field, plyxmodel.ExtensionsDirective):
151
152 # FIXME: models.ExtensionsMax() isn't well defined, so
153 # omit to/from when non-LU is found
154
155 if type(field.fromVal) is plyxhelpers.LU:
156 ed['from'] = field.fromVal.pval
157
158 if type(field.toVal) is plyxhelpers.LU:
159 ed['to'] = field.toVal.pval
160
161 elif isinstance(field, plyxmodel.OptionStatement):
162
163 os[field.name.value.pval] = field.value.value.pval
164
165 else:
166 print("Unknown message type: %s" % type(field))
167 raise BaseException
168
169 return fd, md, en, ed, os
170
171
172def options_dict(protofile):
173 '''returns a dictionary of name:value options from a ProtoFile'''
174
175 options = {}
176
177 for item in protofile.body:
178 if isinstance(item, plyxmodel.OptionStatement):
179 options[item.name.value.pval] = item.value.value.pval
180
181 return options
182
183
184class TestParser(unittest.TestCase):
185
186 @classmethod
187 def setUpClass(self):
188 self.parser = plyxparser.ProtobufAnalyzer()
189
190 def test_invalid_input(self):
191 '''invalid input'''
192
193 t_in = """this is invalid"""
194
195 with self.assertRaises(plyxparser.ParsingError):
196 self.parser.parse_string(t_in)
197
198 def test_package(self):
199 '''creating a package'''
200
201 t_in = """package tutorial;"""
202 p_out = self.parser.parse_string(t_in)
203
204 self.assertIsInstance(p_out, plyxmodel.ProtoFile)
205 self.assertIsInstance(p_out.body[0], plyxmodel.PackageStatement)
206 self.assertEqual(p_out.body[0].name.value[0].pval, "tutorial")
207
208 def test_def_policy(self):
209 '''defining a policy'''
210
211 t_in = """policy foo <exists foo: foo.x=foo.y>"""
212 p_out = self.parser.parse_string(t_in)
213
214 self.assertIsInstance(p_out, plyxmodel.ProtoFile)
215 self.assertIsInstance(p_out.body[0], plyxmodel.PolicyDefinition)
216 self.assertEqual(get_name(p_out.body[0]), "foo")
217 self.assertDictEqual(p_out.body[0].body, {'exists': ['foo', {'=': ('foo.x', 'foo.y')}]})
218
219 def test_def_message(self):
220 '''defining a message'''
221
222 t_in = """package tutorial;
223
224message Person {
225 required string name = 1;
226 required int32 id = 2;
227 optional string email = 3;
228}
229"""
230 p_out = self.parser.parse_string(t_in)
231
232 self.assertIsInstance(p_out, plyxmodel.ProtoFile)
233 # see test_package for testing `package ...` statement
234 self.assertIsInstance(p_out.body[1], plyxmodel.MessageDefinition)
235 self.assertEqual(get_name(p_out.body[1]), 'Person')
236
237 fd, md, en, ed, os = msg_dict(p_out.body[1])
238
239 self.assertDictEqual(fd['name'],
240 {'id': 1, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {}})
241 self.assertDictEqual(fd['id'],
242 {'id': 2, 'modifier': 'required', 'type': 'int32', 'policy': None, 'directives': {}})
243 self.assertDictEqual(fd['email'],
244 {'id': 3, 'modifier': 'optional', 'type': 'string', 'policy': None, 'directives': {}})
245
246 def test_options(self):
247 '''setting options'''
248
249 t_in = """package tutorial;
250option java_outer_classname = "PushNotifications";
251option optimize_for = SPEED;
252"""
253 p_out = self.parser.parse_string(t_in)
254 od = options_dict(p_out)
255
256 self.assertDictEqual(od, {'java_outer_classname': '"PushNotifications"', 'optimize_for': 'SPEED'})
257
258 def test_def_relation(self):
259 '''test defining related messages'''
260
261 t_in = """package tutorial;
262
263message Person(core.Actor) {
264 required string name = 1;
265 required int32 id = 2;
266 optional string email = 3;
267
268 required manytoone work_location->Location/types.Company:employees = 4;
269
270 enum PhoneType {
271 MOBILE = 0;
272 HOME = 1;
273 WORK = 2;
274 }
275
276 message PhoneNumber {
277 required string number = 1;
278 optional PhoneType type = 2 [default = HOME];
279 }
280
281 repeated PhoneNumber phone = 4;
282 extensions 500 to 990;
283}
284
285message AddressBook {
286 repeated Person person = 1;
287
288 // Possible extension numbers.
289 extensions 500 to max;
290}
291"""
292 p_out = self.parser.parse_string(t_in)
293
294 fd, md, en, ed, os = msg_dict(p_out.body[1])
295
296 self.assertDictEqual(fd, {'name': {'id': 1, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {}}, 'id': {'id': 2, 'modifier': 'required', 'type': 'int32', 'policy': None, 'directives': {}}, 'email': {'id': 3, 'modifier': 'optional', 'type': 'string', 'policy': None, 'directives': {}}, 'work_location': {'id': 4, 'modifier': 'required', 'type': 'int32', 'link_type': 'manytoone', 'link_name': 'Location', 'link_src_port': 'work_location', 'link_dst_port': 'employees', 'directives': {'type': 'link', 'model': ['Location'], 'port': 'employees'}}, 'phone': {'id': 4, 'modifier': 'repeated', 'type': 'PhoneNumber', 'policy': None, 'directives': {}}})
297
298 self.assertDictEqual(md, {'PhoneNumber': ({'number': {'id': 1, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {}}, 'type': {'id': 2, 'modifier': 'optional', 'type': 'PhoneType', 'policy': None, 'directives': {'default': 'HOME'}}}, {}, {}, {}, {})})
299 self.assertDictEqual(en, {'PhoneType': {0: 'MOBILE', 1: 'HOME', 2: 'WORK'}})
300 self.assertDictEqual(ed, {'from': '500', 'to': '990'})
301 self.assertDictEqual(os, {})
302
303 fd, md, en, ed, os = msg_dict(p_out.body[2])
304
305 self.assertDictEqual(fd, {'person': {'id': 1, 'modifier': 'repeated', 'type': 'Person', 'policy': None, 'directives': {}}})
306 self.assertDictEqual(md, {})
307 self.assertDictEqual(en, {})
308 self.assertDictEqual(ed, {'from': '500'})
309 self.assertDictEqual(os, {})
310
311 def test_xos_core(self):
312 '''test chunk of xos core xproto'''
313
314 t_in = """
315option app_label = "core";
316option legacy="True";
317
318// use thi policy to allow access to admins only
319policy admin_policy < ctx.user.is_admin >
320
321message XOSBase {
322 option skip_init = True;
323 option custom_header = "xosbase_header";
324 option abstract = True;
325
326 // field 1 is reserved for "id"
327 required string created = 2 [content_type = "date", auto_now_add = True, help_text = "Time this model was created"];
328 required string updated = 3 [default = "now()", content_type = "date", help_text = "Time this model was changed by a non-synchronizer"];
329 optional string enacted = 4 [null = True, content_type = "date", blank = True, default = None, help_text = "When synced, set to the timestamp of the data that was synced"];
330 optional string policed = 5 [null = True, content_type = "date", blank = True, default = None, help_text = "When policed, set to the timestamp of the data that was policed"];
331 optional string backend_register = 6 [default = "{}", max_length = 1024, feedback_state = True];
332 required bool backend_need_delete = 7 [default = False, blank = True];
333 required bool backend_need_reap = 8 [default = False, blank = True];
334 required string backend_status = 9 [default = "Provisioning in progress", max_length = 1024, null = True, feedback_state = True];
335 required int32 backend_code = 10 [default = 0, feedback_state = True];
336 required bool deleted = 11 [default = False, blank = True];
337 required bool write_protect = 12 [default = False, blank = True];
338 required bool lazy_blocked = 13 [default = False, blank = True];
339 required bool no_sync = 14 [default = False, blank = True];
340 required bool no_policy = 15 [default = False, blank = True];
341 optional string policy_status = 16 [default = "Policy in process", max_length = 1024, feedback_state = True];
342 optional int32 policy_code = 17 [default = 0, feedback_state = True];
343 required string leaf_model_name = 18 [null = False, max_length = 1024, help_text = "The most specialized model in this chain of inheritance, often defined by a service developer"];
344 required bool backend_need_delete_policy = 19 [default = False, help_text = "True if delete model_policy must be run before object can be reaped", blank = True];
345 required bool xos_managed = 20 [default = True, help_text = "True if xos is responsible for creating/deleting this object", blank = True, gui_hidden = True];
346 optional string backend_handle = 21 [max_length = 1024, feedback_state = True, blank=True, null=True, help_text = "Handle used by the backend to track this object", gui_hidden = True];
347 optional string changed_by_step = 22 [null = True, content_type = "date", blank = True, default = None, gui_hidden = True, help_text = "Time this model was changed by a sync step"];
348 optional string changed_by_policy = 23 [null = True, content_type = "date", blank = True, default = None, gui_hidden = True, help_text = "Time this model was changed by a model policy"];
349}
350
351// A user may give a permission that he has to another user
352policy grant_policy < ctx.user.is_admin
353 | exists Privilege:Privilege.object_type = obj.object_type
354 & Privilege.object_id = obj.object_id
355 & Privilege.accessor_type = "User"
356 & Privilege.accessor_id = ctx.user.id
357 & Privilege.permission = "role:admin" >
358
359message Privilege::grant_policy (XOSBase) {
360 required int32 accessor_id = 1 [null = False, blank=False];
361 required string accessor_type = 2 [null = False, max_length=1024, blank = False];
362 optional int32 controller_id = 3 [null = True, blank = True];
363 required int32 object_id = 4 [null = False, blank=False];
364 required string object_type = 5 [null = False, max_length=1024, blank = False];
365 required string permission = 6 [null = False, default = "all", max_length=1024, tosca_key=True];
366 required string granted = 7 [content_type = "date", auto_now_add = True, max_length=1024];
367 required string expires = 8 [content_type = "date", null = True, max_length=1024];
368}
369"""
370
371 p_out = self.parser.parse_string(t_in)
372
373 # check options
374 od = options_dict(p_out)
375 self.assertDictEqual(od, {'app_label': '"core"', 'legacy': '"True"'})
376
377 self.assertIsInstance(p_out.body[2], plyxmodel.PolicyDefinition)
378 self.assertEqual(get_name(p_out.body[2]), "admin_policy")
379 self.assertEqual(p_out.body[2].body, 'ctx.user.is_admin')
380
381 fd, md, en, ed, os = msg_dict(p_out.body[3])
382
383 self.assertDictEqual(fd, {'created': {'id': 2, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {'content_type': '"date"', 'auto_now_add': 'True', 'help_text': '"Time this model was created"'}}, 'updated': {'id': 3, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {'default': '"now()"', 'content_type': '"date"', 'help_text': '"Time this model was changed by a non-synchronizer"'}}, 'enacted': {'id': 4, 'modifier': 'optional', 'type': 'string', 'policy': None, 'directives': {'null': 'True', 'content_type': '"date"', 'blank': 'True', 'default': 'None', 'help_text': '"When synced, set to the timestamp of the data that was synced"'}}, 'policed': {'id': 5, 'modifier': 'optional', 'type': 'string', 'policy': None, 'directives': {'null': 'True', 'content_type': '"date"', 'blank': 'True', 'default': 'None', 'help_text': '"When policed, set to the timestamp of the data that was policed"'}}, 'backend_register': {'id': 6, 'modifier': 'optional', 'type': 'string', 'policy': None, 'directives': {'default': '"{}"', 'max_length': '1024', 'feedback_state': 'True'}}, 'backend_need_delete': {'id': 7, 'modifier': 'required', 'type': 'bool', 'policy': None, 'directives': {'default': 'False', 'blank': 'True'}}, 'backend_need_reap': {'id': 8, 'modifier': 'required', 'type': 'bool', 'policy': None, 'directives': {'default': 'False', 'blank': 'True'}}, 'backend_status': {'id': 9, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {'default': '"Provisioning in progress"', 'max_length': '1024', 'null': 'True', 'feedback_state': 'True'}}, 'backend_code': {'id': 10, 'modifier': 'required', 'type': 'int32', 'policy': None, 'directives': {'default': '0', 'feedback_state': 'True'}}, 'deleted': {'id': 11, 'modifier': 'required', 'type': 'bool', 'policy': None, 'directives': {'default': 'False', 'blank': 'True'}}, 'write_protect': {'id': 12, 'modifier': 'required', 'type': 'bool', 'policy': None, 'directives': {'default': 'False', 'blank': 'True'}}, 'lazy_blocked': {'id': 13, 'modifier': 'required', 'type': 'bool', 'policy': None, 'directives': {'default': 'False', 'blank': 'True'}}, 'no_sync': {'id': 14, 'modifier': 'required', 'type': 'bool', 'policy': None, 'directives': {'default': 'False', 'blank': 'True'}}, 'no_policy': {'id': 15, 'modifier': 'required', 'type': 'bool', 'policy': None, 'directives': {'default': 'False', 'blank': 'True'}}, 'policy_status': {'id': 16, 'modifier': 'optional', 'type': 'string', 'policy': None, 'directives': {'default': '"Policy in process"', 'max_length': '1024', 'feedback_state': 'True'}}, 'policy_code': {'id': 17, 'modifier': 'optional', 'type': 'int32', 'policy': None, 'directives': {'default': '0', 'feedback_state': 'True'}}, 'leaf_model_name': {'id': 18, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {'null': 'False', 'max_length': '1024', 'help_text': '"The most specialized model in this chain of inheritance, often defined by a service developer"'}}, 'backend_need_delete_policy': {'id': 19, 'modifier': 'required', 'type': 'bool', 'policy': None, 'directives': {'default': 'False', 'help_text': '"True if delete model_policy must be run before object can be reaped"', 'blank': 'True'}}, 'xos_managed': {'id': 20, 'modifier': 'required', 'type': 'bool', 'policy': None, 'directives': {'default': 'True', 'help_text': '"True if xos is responsible for creating/deleting this object"', 'blank': 'True', 'gui_hidden': 'True'}}, 'backend_handle': {'id': 21, 'modifier': 'optional', 'type': 'string', 'policy': None, 'directives': {'max_length': '1024', 'feedback_state': 'True', 'blank': 'True', 'null': 'True', 'help_text': '"Handle used by the backend to track this object"', 'gui_hidden': 'True'}}, 'changed_by_step': {'id': 22, 'modifier': 'optional', 'type': 'string', 'policy': None, 'directives': {'null': 'True', 'content_type': '"date"', 'blank': 'True', 'default': 'None', 'gui_hidden': 'True', 'help_text': '"Time this model was changed by a sync step"'}}, 'changed_by_policy': {'id': 23, 'modifier': 'optional', 'type': 'string', 'policy': None, 'directives': {'null': 'True', 'content_type': '"date"', 'blank': 'True', 'default': 'None', 'gui_hidden': 'True', 'help_text': '"Time this model was changed by a model policy"'}}})
384
385 self.assertDictEqual(md, {})
386 self.assertDictEqual(en, {})
387 self.assertDictEqual(ed, {})
388 self.assertDictEqual(os, {'skip_init': 'True', 'custom_header': '"xosbase_header"', 'abstract': 'True'})
389
390 self.assertIsInstance(p_out.body[4], plyxmodel.PolicyDefinition)
391
392 self.assertEqual(get_name(p_out.body[4]), "grant_policy")
393 self.assertDictEqual(p_out.body[4].body, {'|':
394 ['ctx.user.is_admin', {'&':
395 [{'&': [{'&': [{'&': [{'exists': ['Privilege', {'=': ('Privilege.object_type', 'obj.object_type')}]},
396 {'=': ('Privilege.object_id', 'obj.object_id')}]},
397 {'=': ('Privilege.accessor_type', '"User"')}]},
398 {'=': ('Privilege.accessor_id', 'ctx.user.id')}]},
399 {'=': ('Privilege.permission', '"role:admin"')}]}]}
400 )
401 fd, md, en, ed, os = msg_dict(p_out.body[5])
402
403 self.assertDictEqual(fd, {'accessor_id': {'id': 1, 'modifier': 'required', 'type': 'int32', 'policy': None, 'directives': {'null': 'False', 'blank': 'False'}}, 'accessor_type': {'id': 2, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {'null': 'False', 'max_length': '1024', 'blank': 'False'}}, 'controller_id': {'id': 3, 'modifier': 'optional', 'type': 'int32', 'policy': None, 'directives': {'null': 'True', 'blank': 'True'}}, 'object_id': {'id': 4, 'modifier': 'required', 'type': 'int32', 'policy': None, 'directives': {'null': 'False', 'blank': 'False'}}, 'object_type': {'id': 5, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {'null': 'False', 'max_length': '1024', 'blank': 'False'}}, 'permission': {'id': 6, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {'null': 'False', 'default': '"all"', 'max_length': '1024', 'tosca_key': 'True'}}, 'granted': {'id': 7, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {'content_type': '"date"', 'auto_now_add': 'True', 'max_length': '1024'}}, 'expires': {'id': 8, 'modifier': 'required', 'type': 'string', 'policy': None, 'directives': {'content_type': '"date"', 'null': 'True', 'max_length': '1024'}}})
404
405 self.assertDictEqual(md, {})
406 self.assertDictEqual(en, {})
407 self.assertDictEqual(ed, {})
408 self.assertDictEqual(os, {})
409
410# FIXME: these sorts of simple validations should fail but currently don't
411#
412# def test_invalid_message(self):
413# '''an invalid message'''
414#
415# t_in = """message BadMessage {
416# required string name = 0;
417# required int32 id = -2;
418# }"""
419# p_out = self.parser.parse_string(t_in)
420# print(p_out)
421#
422#
423# def test_duplicate_id(self):
424# '''duplicate id's in a message'''
425#
426# t_in = """message BadMessage2 {
427# required string name = 1;
428# required int32 id = 1;
429# }"""
430# p_out = self.parser.parse_string(t_in)
431# print(p_out)