Mercurial > hg > freeDiameter
comparison contrib/tools/csv_to_fd @ 1461:a86eb3375b95
add csv_to_fd, org_to_csv tools
csv_to_fd converts CSV files containing RADIUS or Diameter AVP tables
into various formats, including freeDiameter C code and JSON documents.
org_to_csv converts org files into CSV files, suitable for csv_to_fd.
author | Luke Mewburn <luke@mewburn.net> |
---|---|
date | Mon, 09 Mar 2020 21:24:24 +1100 |
parents | |
children | 8f6c77f24b1a |
comparison
equal
deleted
inserted
replaced
1460:4f44d206e60d | 1461:a86eb3375b95 |
---|---|
1 #!/usr/bin/env python | |
2 # vim: set fileencoding=utf-8 : | |
3 | |
4 """ | |
5 Convert CSV files containing RADIUS or Diameter AVP tables | |
6 into various formats. | |
7 | |
8 Format of the CSV files is one of: | |
9 - Row per 3GPP AVP tables: | |
10 Name, Code, Section, DataType, Must, May, ShouldNot, MustNot [, ...] | |
11 - Name: | |
12 AVP Name. String, validated as ALPHA *(ALPHA / DIGIT / "-") | |
13 - Code: | |
14 AVP Code. Integer, 0..4294967295. | |
15 - Section: | |
16 Section in relevant standard. String. | |
17 - DataType: | |
18 AVP Data Type. String, validated per Per RFC 6733 § 4.2 and § 4.3. | |
19 - Must, May, ShouldNot, MustNot: | |
20 Flags, possibly comma or space separated: M, V | |
21 | |
22 - Comment row. First cell: | |
23 # Comment text Comment text | |
24 #= Header row of ==== | |
25 # Blank line | |
26 | |
27 - Parameter row: | |
28 @Parameter,Value [, ...] | |
29 Supported Parameter terms: | |
30 standard Standard name. E.g. '3GPP TS 29.272', 'RFC 6733'. | |
31 vendor Vendor number. | |
32 | |
33 """ | |
34 | |
35 from __future__ import print_function | |
36 from __future__ import with_statement | |
37 | |
38 import abc | |
39 import csv | |
40 import collections | |
41 import json | |
42 import re | |
43 import optparse | |
44 import sys | |
45 | |
46 CSV_COLUMN_NAMES = [ | |
47 'name', | |
48 'code', | |
49 'section', | |
50 'datatype', | |
51 'must', | |
52 'may', | |
53 'shouldnot', | |
54 'mustnot', | |
55 'encrypt', | |
56 ] | |
57 | |
58 VENDOR_TO_NAME = { | |
59 0: '', | |
60 193: 'Ericsson', | |
61 8164: 'Starent', | |
62 10415: '3GPP', | |
63 } | |
64 | |
65 | |
66 class Avp(object): | |
67 """Store an AVP row.""" | |
68 | |
69 # Regex to validate avp-name per RFC 6733 § 3.2, | |
70 # with changes: | |
71 # - Allow avp-name to start with numbers (for 3GPP) | |
72 # - Allow '.' in avp-name, for existing dict_dcca_3gpp usage. | |
73 # TODO: if starts with digit, ensure contains a letter somewhere? | |
74 _name_re = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9-\.]*$') | |
75 | |
76 __slots__ = CSV_COLUMN_NAMES + [ | |
77 'filename', 'linenum', 'standard', 'vendor', ] | |
78 | |
79 def __init__(self, name, code, section, datatype, | |
80 must, may, shouldnot, mustnot, encrypt, | |
81 filename=None, linenum=0, standard=None, vendor=0): | |
82 # Members from CSV row | |
83 self.name = name | |
84 self.code = int(code) | |
85 self.section = section | |
86 self.datatype = datatype | |
87 self.must = must | |
88 self.may = may | |
89 self.shouldnot = shouldnot | |
90 self.mustnot = mustnot | |
91 self.encrypt = encrypt | |
92 # Members from file state | |
93 self.filename = filename | |
94 self.linenum = linenum | |
95 self.standard = standard | |
96 self.vendor = vendor | |
97 # Validate CSV fields | |
98 if not self._name_re.match(self.name): | |
99 raise ValueError('Invalid AVP name "{}"'.format(self.name)) | |
100 if (self.code < 0 or self.code > 4294967295): | |
101 raise ValueError('Invalid AVP code {}'.format(self.code)) | |
102 if self.datatype not in ( | |
103 'OctetString', 'Integer32', 'Integer64', 'Unsigned32', | |
104 'Unsigned64', 'Float32', 'Float64', 'Grouped', | |
105 'Address', 'Time', 'UTF8String', 'DiameterIdentity', | |
106 'DiameterURI', 'Enumerated', 'IPFilterRule', | |
107 ): | |
108 raise ValueError('Invalid AVP data type "{}"'.format( | |
109 self.datatype)) | |
110 # TODO: validate must, may, shouldnot, mustnot | |
111 | |
112 @property | |
113 def __dict__(self): | |
114 return {s: getattr(self, s) for s in self.__slots__} | |
115 | |
116 | |
117 class Processor(object): | |
118 """Interface for processor of Avp""" | |
119 | |
120 __metaclass__ = abc.ABCMeta | |
121 | |
122 @classmethod | |
123 def cls_name(cls): | |
124 """Return the name, lower-case, without "processor" suffix.""" | |
125 suffix = 'processor' | |
126 name = cls.__name__.lower() | |
127 if name.endswith(suffix): | |
128 return name[:-len(suffix)] | |
129 return name | |
130 | |
131 @classmethod | |
132 def cls_desc(cls): | |
133 """Return the first line of the docstring.""" | |
134 if cls.__doc__ is None: | |
135 return "" | |
136 return cls.__doc__.split('\n')[0] | |
137 | |
138 @abc.abstractmethod | |
139 def next_file(self, filename): | |
140 """Called when a file is opened.""" | |
141 pass | |
142 | |
143 @abc.abstractmethod | |
144 def avp(self, avp): | |
145 """Process a validated Avp.""" | |
146 pass | |
147 | |
148 @abc.abstractmethod | |
149 def comment(self, comment, filename, linenum): | |
150 """Process a comment row: | |
151 #comment, | |
152 """ | |
153 pass | |
154 | |
155 @abc.abstractmethod | |
156 def generate(self): | |
157 """Invoked after all rows processed.""" | |
158 pass | |
159 | |
160 | |
161 class DebugProcessor(Processor): | |
162 """Display the CSV parsing""" | |
163 | |
164 def next_file(self, filename): | |
165 print('File: {}'.format(filename)) | |
166 | |
167 def avp(self, avp): | |
168 avpdict = vars(avp) | |
169 print('AVP: {name}, {code}, {datatype}'.format(**avpdict)) | |
170 | |
171 def comment(self, comment, filename, linenum): | |
172 print('Comment: {}'.format(comment)) | |
173 | |
174 def generate(self): | |
175 print('Generate') | |
176 | |
177 | |
178 class NoopProcessor(Processor): | |
179 """Validate the CSV; no other output""" | |
180 | |
181 def next_file(self, filename): | |
182 pass | |
183 | |
184 def avp(self, avp): | |
185 pass | |
186 | |
187 def comment(self, comment, filename, linenum): | |
188 pass | |
189 | |
190 def generate(self): | |
191 pass | |
192 | |
193 | |
194 class FdcProcessor(Processor): | |
195 """Generate freeDiameter C code | |
196 | |
197 Comment cells are parsed as: | |
198 # text comment /* text comment */ | |
199 #= /*==============*/ | |
200 # [blank line] | |
201 """ | |
202 | |
203 COMMENT_WIDTH = 64 | |
204 | |
205 DERIVED_TO_BASE = { | |
206 'Address': 'OctetString', | |
207 'Time': 'OctetString', | |
208 'UTF8String': 'OctetString', | |
209 'DiameterIdentity': 'OctetString', | |
210 'DiameterURI': 'OctetString', | |
211 'Enumerated': 'Integer32', | |
212 'IPFilterRule': 'OctetString', | |
213 } | |
214 | |
215 def __init__(self): | |
216 self.lines = [] | |
217 | |
218 def next_file(self, filename): | |
219 print('/* CSV file: {} */'.format(filename)) | |
220 | |
221 def avp(self, avp): | |
222 comment = '{name}, {datatype}, code {code}'.format(**vars(avp)) | |
223 if '' != avp.section: | |
224 comment += ', section {}'.format(avp.section) | |
225 self.add_comment(comment) | |
226 self.add('\t{') | |
227 self.add('\t\tstruct dict_avp_data data = {') | |
228 # TODO: remove comments? | |
229 self.add('\t\t\t{},\t/* Code */'.format(avp.code)) | |
230 self.add('\t\t\t{},\t/* Vendor */'.format(avp.vendor)) | |
231 self.add('\t\t\t\"{}\",\t/* Name */'.format(avp.name)) | |
232 self.add('\t\t\t{},\t/* Fixed flags */'.format( | |
233 self.build_flags(', '.join([avp.must, avp.mustnot])))) | |
234 self.add('\t\t\t{},\t/* Fixed flag values */'.format( | |
235 self.build_flags(avp.must))) | |
236 # TODO: add trailing comma? | |
237 self.add('\t\t\tAVP_TYPE_{}\t/* base type of data */'.format( | |
238 self.DERIVED_TO_BASE.get( | |
239 avp.datatype, avp.datatype).upper())) | |
240 self.add('\t\t};') | |
241 avp_type = 'NULL' | |
242 if 'Enumerated' == avp.datatype: | |
243 self.add('\t\tstruct dict_object\t*type;') | |
244 vendor_prefix = '' | |
245 if avp.vendor != 0: | |
246 vendor_prefix = '{}/'.format(VENDOR_TO_NAME[avp.vendor]) | |
247 self.add( | |
248 '\t\tstruct dict_type_data\t tdata = {{ AVP_TYPE_INTEGER32, ' | |
249 '"Enumerated({prefix}{name})", NULL, NULL, NULL }};'.format( | |
250 prefix=vendor_prefix, name=avp.name)) | |
251 # XXX: add enumerated values | |
252 self.add('\t\tCHECK_dict_new(DICT_TYPE, &tdata, NULL, &type);') | |
253 avp_type = "type" | |
254 elif avp.datatype in self.DERIVED_TO_BASE: | |
255 avp_type = '{}_type'.format(avp.datatype) | |
256 self.add('\t\tCHECK_dict_new(DICT_AVP, &data, {}, NULL);'.format( | |
257 avp_type)) | |
258 # TODO: remove ; on scope brace | |
259 self.add('\t};') | |
260 self.add('') | |
261 | |
262 def comment(self, comment, filename, linenum): | |
263 if '' == comment: | |
264 self.add('') | |
265 elif '=' == comment: | |
266 self.add_header() | |
267 elif comment.startswith(' '): | |
268 self.add_comment(comment[1:]) | |
269 else: | |
270 raise ValueError('Unsupported comment "{}"'.format(comment)) | |
271 | |
272 def generate(self): | |
273 self.print_header() | |
274 self.print_comment('Start of generated data.') | |
275 self.print_comment('') | |
276 self.print_comment('The following is created automatically with:') | |
277 self.print_comment(' csv_to_fd -p {}'.format(self.cls_name())) | |
278 self.print_comment('Changes will be lost during the next update.') | |
279 self.print_comment('Do not modify;' | |
280 ' modify the source .csv file instead.') | |
281 self.print_header() | |
282 print('') | |
283 print('\n'.join(self.lines)) | |
284 self.print_header() | |
285 self.print_comment('End of generated data.') | |
286 self.print_header() | |
287 | |
288 def build_flags(self, flags): | |
289 result = [] | |
290 if 'V' in flags: | |
291 result.append('AVP_FLAG_VENDOR') | |
292 if 'M' in flags: | |
293 result.append('AVP_FLAG_MANDATORY') | |
294 return ' |'.join(result) | |
295 | |
296 def add(self, line): | |
297 self.lines.append(line) | |
298 | |
299 def add_comment(self, comment): | |
300 self.lines.append(self.format_comment(comment)) | |
301 | |
302 def add_header(self): | |
303 self.lines.append(self.format_header()) | |
304 | |
305 def format_comment(self, comment): | |
306 return '\t/* {:<{width}} */'.format(comment, width=self.COMMENT_WIDTH) | |
307 | |
308 def format_header(self): | |
309 return '\t/*={:=<{width}}=*/'.format('', width=self.COMMENT_WIDTH) | |
310 | |
311 def print_comment(self, comment): | |
312 print(self.format_comment(comment)) | |
313 | |
314 def print_header(self): | |
315 print(self.format_header()) | |
316 | |
317 | |
318 class JsonProcessor(Processor): | |
319 """Generate freeDiameter JSON object | |
320 """ | |
321 | |
322 def __init__(self): | |
323 self.avps = [] | |
324 | |
325 def next_file(self, filename): | |
326 pass | |
327 | |
328 def avp(self, avp): | |
329 flags = collections.OrderedDict([ | |
330 ('Must', self.build_flags(avp.must)), | |
331 ('MustNot', self.build_flags(avp.mustnot)), | |
332 ]) | |
333 row = collections.OrderedDict([ | |
334 ('Code', avp.code), | |
335 ('Flags', flags), | |
336 ('Name', avp.name), | |
337 ('Type', avp.datatype), | |
338 ('Vendor', avp.vendor), | |
339 ]) | |
340 self.avps.append(row) | |
341 | |
342 def comment(self, comment, filename, linenum): | |
343 pass | |
344 | |
345 def generate(self): | |
346 doc = {"AVPs": self.avps} | |
347 print(json.dumps(doc, indent=2)) | |
348 | |
349 def build_flags(self, flags): | |
350 result = [] | |
351 if 'V' in flags: | |
352 result.append('V') | |
353 if 'M' in flags: | |
354 result.append('M') | |
355 return ''.join(result) | |
356 | |
357 | |
358 def main(): | |
359 | |
360 # Build dict of name: NameProcessor | |
361 processors = { | |
362 cls.cls_name(): cls | |
363 for cls in Processor.__subclasses__() | |
364 } | |
365 | |
366 # Build Processor name to desc | |
367 processor_help = '\n'.join( | |
368 [' {:8} {}'.format(key, processors[key].cls_desc()) | |
369 for key in sorted(processors)]) | |
370 | |
371 # Custom OptionParser with improved help | |
372 class MyParser(optparse.OptionParser): | |
373 """Custom OptionParser without epilog formatting.""" | |
374 def format_help(self, formatter=None): | |
375 return """\ | |
376 {} | |
377 Supported PROCESSOR options: | |
378 {} | |
379 """.format( | |
380 optparse.OptionParser.format_help(self, formatter), | |
381 processor_help) | |
382 | |
383 # Parse options | |
384 parser = MyParser( | |
385 description="""\ | |
386 Convert CSV files containing RADIUS or Diameter AVP tables | |
387 into various formats using the specified processor PROCESSOR. | |
388 """) | |
389 | |
390 parser.add_option( | |
391 '-p', '--processor', | |
392 default='noop', | |
393 help='AVP processor. One of: {}. [%default]'.format( | |
394 ', '.join(processors.keys()))) | |
395 (opts, args) = parser.parse_args() | |
396 if len(args) < 1: | |
397 parser.error('Incorrect number of arguments') | |
398 | |
399 # Find processor | |
400 try: | |
401 avpproc = processors[opts.processor]() | |
402 except KeyError as e: | |
403 parser.error('Unknown processor "{}"'.format(opts.processor)) | |
404 | |
405 # dict of [vendor][code] : Avp | |
406 avp_codes = collections.defaultdict(dict) | |
407 | |
408 # Process files | |
409 for filename in args: | |
410 avpproc.next_file(filename) | |
411 with open(filename, 'r') as csvfile: | |
412 csvdata = csv.DictReader(csvfile, CSV_COLUMN_NAMES) | |
413 linenum = 0 | |
414 standard = '' | |
415 vendor = 0 | |
416 for row in csvdata: | |
417 linenum += 1 | |
418 try: | |
419 if row['name'] in (None, '', 'Attribute Name'): | |
420 continue | |
421 elif row['name'].startswith('#'): | |
422 comment = row['name'][1:] | |
423 avpproc.comment(comment, filename, linenum) | |
424 elif row['name'].startswith('@'): | |
425 parameter = row['name'][1:] | |
426 value = row['code'] | |
427 if False: | |
428 pass | |
429 elif 'standard' == parameter: | |
430 standard = value | |
431 elif 'vendor' == parameter: | |
432 vendor = int(value) | |
433 else: | |
434 raise ValueError('Unknown parameter "{}"'.format( | |
435 parameter)) | |
436 else: | |
437 avp = Avp(filename=filename, linenum=linenum, | |
438 standard=standard, vendor=vendor, | |
439 **row) | |
440 # Ensure AVP vendor/code not already defined | |
441 if avp.code in avp_codes[avp.vendor]: | |
442 conflict = avp_codes[avp.vendor][avp.code] | |
443 raise ValueError( | |
444 'AVP vendor {} code {} already present' | |
445 ' in file "{}" line {}'.format( | |
446 avp.vendor, avp.code, | |
447 conflict.filename, conflict.linenum)) | |
448 avp_codes[avp.vendor][avp.code] = avp | |
449 # Process AVP | |
450 avpproc.avp(avp) | |
451 except ValueError as e: | |
452 sys.stderr.write('CSV file "{}" line {}: {}: {}\n'.format( | |
453 filename, linenum, e.__class__.__name__, e)) | |
454 sys.exit(1) | |
455 | |
456 # Generate result | |
457 avpproc.generate() | |
458 | |
459 | |
460 if '__main__' == __name__: | |
461 main() | |
462 | |
463 # vim: set et sw=4 sts=4 : |