dragonflow/dragonflow/cli/df_model.py

648 lines
22 KiB
Python

# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import print_function
import abc
import argparse
import contextlib
import six
import sys
from jsonmodels import fields
from oslo_serialization import jsonutils
import prettytable
from dragonflow.db import field_types
from dragonflow.db import model_framework
from dragonflow.db.models import all # noqa
STRING_TYPE = 'string'
NUMBER_TYPE = 'number'
FLOAT_TYPE = 'float'
BOOL_TYPE = 'boolean'
ENUM_TYPE = 'enum'
BASIC_TYPES = (STRING_TYPE, NUMBER_TYPE, FLOAT_TYPE, BOOL_TYPE, ENUM_TYPE)
@six.add_metaclass(abc.ABCMeta)
class ModelsPrinter(object):
"""Abstract base class for the different format printers.
Every specific format printer should inherit from this class and
implement the methods for the specific output format.
All the models are handled one after the other, and it is guaranteed
that if a model depends on another model, the dependant model will
be handled _after_ the model it depends on.
"""
def __init__(self, fh):
"""Basic constructor for the base class.
:param fh: file handler of the output stream to write to
:type fh: file object
"""
self._output = fh
def _print(self, *args, **kwargs):
print(*args, file=self._output, **kwargs)
def output_start(self):
"""Handler called once before any processing is done.
This should be used for initialization or to print any prefix the
specific output format requires.
"""
pass
def output_end(self):
"""Handler called once after all processing is done.
This should be used for cleanup or to print any remaining data or
suffixes the specific output format requires.
"""
pass
def model_start(self, model_name):
"""Handler called once per model, before processing the model.
This should be used to clean/initialize any data specific for the
handling of the model.
:param model_name: the name of the model
:type model_name: string
"""
pass
def model_end(self, model_name):
"""Handler called once per model, after processing the model.
This should be used to cleanup or to print any remaining data or
suffixes specific for the handling of the model.
:param model_name: the name of the model
:type model_name: string
"""
pass
def fields_start(self):
"""Handler called once per model, before processing the fields.
This should be used to initialize any data specific for the
handling of the model fields.
"""
pass
def fields_end(self):
"""Handler called once per model, after processing all the fields.
This should be used to cleanup any data specific for the handling
of the model fields.
"""
pass
@abc.abstractmethod
def handle_field(self, field_name, field_type, is_required, is_embedded,
is_single, restrictions):
"""Handler called once per field in a model.
This should be used to print the specific field.
:param field_name: the name of the field
:type field_name: string
:param field_type: string - the type of the field (e.g. number)
:type field_type: string
:param is_required: True iff the field is a required field for the
specific model
:type is_required: bool
:param is_embedded: True iff the field is not a reference to another
object, but rather an object that is part of the model
:type is_embedded: bool
:param is_single: True iff the field is single-valued
:type is_single: bool
:param restrictions: representation of any restrictions on the field
(e.g. list of possible values for enum)
:type restrictions: string
"""
pass
def indexes_start(self):
"""Handler called once per model, before processing the indexes.
The indexes are fields that are marked as indexes, thus are a subset
of the list of model indexes.
This should be used to initialize any data specific for the
handling of the model indexes.
In case there are no indexes for this model, this method will not be
called.
"""
pass
def indexes_end(self):
"""Handler called once per model, after processing all the indexes.
This should be used to cleanup any data specific for the handling
of the model indexes.
In case there are no indexes for this model, this method will not be
called.
"""
pass
@abc.abstractmethod
def handle_index(self, index_name):
"""Handler called once per index in a model.
This should be used to print the specific index.
:param index_name: the name of the index field
:type index_name: string
"""
pass
def events_start(self):
"""Handler called once per model, before processing the events.
This should be used to initialize any data specific for the
handling of the model events.
In case there are no events for this model, this method will not be
called.
"""
pass
def events_end(self):
"""Handler called once per model, after processing all the events.
This should be used to cleanup any data specific for the handling
of the model events.
In case there are no events for this model, this method will not be
called.
"""
pass
@abc.abstractmethod
def handle_event(self, event_name):
"""Handler called once per event in a model.
This should be used to print the specific event.
:param event_name: the name of the event
:type event_name: string
"""
pass
class PlaintextPrinter(ModelsPrinter):
"""ModelPrinter that prints to simple plaintext format.
This printer prints the models in the most simple way.
"""
def __init__(self, fh):
super(PlaintextPrinter, self).__init__(fh)
def model_start(self, model_name):
self._print('-------------')
self._print('{}'.format(model_name))
self._print('-------------')
def model_end(self, model_name):
self._print('')
def fields_start(self):
self._print('Fields')
self._print('------')
def handle_field(self, field_name, field_type, is_required, is_embedded,
is_single, restrictions):
restriction_str = ' {}'.format(restrictions) if restrictions else ''
self._print('{name} : {type}{restriction}{required}{multi}'.format(
name=field_name, type=field_type, restriction=restriction_str,
required=', Required' if is_required else '',
multi=', Multi' if not is_single else '',
embedded=', Embedded' if is_embedded else ''))
def indexes_start(self):
self._print('Indexes')
self._print('-------')
def handle_index(self, index_name):
self._print('{}'.format(index_name))
def events_start(self):
self._print('Events')
self._print('------')
def handle_event(self, event_name):
self._print('{}'.format(event_name))
class UMLPrinter(ModelsPrinter):
"""ModelPrinter that prints to UML format.
This printer prints the models in PlantUML format.
"""
def __init__(self, fh):
super(UMLPrinter, self).__init__(fh)
self._model = ''
self._processed = set()
self._dependencies = set()
def output_start(self):
self._print('@startuml')
self._print('hide circle')
def _output_relations(self):
for (dst, src, name, is_single, is_embedded) in self._dependencies:
if src in self._processed:
if is_embedded:
connector_str = ' *-- ' if is_single else '"1" *-- "*"'
else:
connector_str = ' o-- ' if is_single else ' o-- "*"'
self._print('{dest} {connector} {src} : {field_name}'.format(
dest=dst, connector=connector_str, src=src,
field_name=name))
def output_end(self):
self._output_relations()
self._print('@enduml')
def model_start(self, model_name):
self._model = model_name
self._print('class {} {{'.format(model_name))
def model_end(self, model_name):
self._print('}')
self._processed.add(model_name)
self._model = ''
def handle_field(self, field_name, field_type, is_required, is_embedded,
is_single, restrictions):
restriction_str = ' {}'.format(restrictions) if restrictions else ''
name = '<b>{}</b>'.format(field_name) if is_required else field_name
self._print(' +{name} : {type} {restriction}'.format(
name=name, type=field_type, restriction=restriction_str))
self._dependencies.add((self._model, field_type, field_name,
is_single, is_embedded))
def indexes_start(self):
self._print(' .. Indexes ..')
def handle_index(self, index_name):
self._print(' {}'.format(index_name))
def events_start(self):
self._print(' == Events ==')
def handle_event(self, event_name):
self._print(' {}'.format(event_name))
class OASPrinter(ModelsPrinter):
"""ModelPrinter that prints to JSON format
This printer prints the models in JSON format.
Specifically, it uses the OpenApiSchema format.
"""
_OPENAPI_VERSION = '3.0.0'
_MODEL_SCHEMA_VERSION = '0.0.1'
_SCHEMA_BASE_PATH = '#/components/schemas'
_INFO_TITLE = 'DragonFlow Schema'
_INFO_DESC = 'jsonschma representation of the DragonFlow model'
_LIC_NAME = 'Apache 2.0'
_LIC_URL = 'http://www.apache.org/licenses/LICENSE-2.0.html'
def __init__(self, fh):
super(OASPrinter, self).__init__(fh)
self._required = list()
self._base_types = BASIC_TYPES
self._models_obj = dict()
self._model = dict()
def output_start(self):
info = dict()
license = dict()
paths = dict()
schemas = dict()
components = dict()
self._models_obj['openapi'] = OASPrinter._OPENAPI_VERSION
self._models_obj['info'] = info
info['title'] = OASPrinter._INFO_TITLE
info['description'] = OASPrinter._INFO_DESC
info['license'] = license
license['name'] = OASPrinter._LIC_NAME
license['url'] = OASPrinter._LIC_URL
info['version'] = OASPrinter._MODEL_SCHEMA_VERSION
self._models_obj['paths'] = paths
self._models_obj['components'] = components
components['schemas'] = schemas
def output_end(self):
jsonutils.dump(self._models_obj, self._output, indent=2)
def model_start(self, model_name):
self._required = list()
self._model = dict()
self._models_obj['components']['schemas'][model_name] = self._model
self._model['type'] = 'object'
def model_end(self, model_name):
if len(self._required) > 0:
self._model['required'] = self._required
def fields_start(self):
self._model['properties'] = dict()
def fields_end(self):
pass
def _simple_field(self, field_type, restrictions):
if field_type in self._base_types:
return {'type': field_type}
elif field_type == ENUM_TYPE:
return {field_type: list(restrictions)}
else:
return {'$ref': '{}/{}'.format(OASPrinter._SCHEMA_BASE_PATH,
field_type)}
def _array_field(self, field_type, restrictions):
return {'items': self._simple_field(field_type, restrictions),
'type': 'array'}
def handle_field(self, field_name, field_type, is_required, is_embedded,
is_single, restrictions):
flds = self._model['properties']
if is_single:
flds[field_name] = self._simple_field(field_type, restrictions)
else:
flds[field_name] = self._array_field(field_type, restrictions)
if is_required:
self._required.append(field_name)
def handle_index(self, index_name):
pass
def handle_event(self, event_name):
pass
class RstPrinter(ModelsPrinter):
"""ModelPrinter that prints to reStructuredText format.
This printer prints output that can be reused and included in rst files
for documentation purposes.
"""
def __init__(self, fh):
super(RstPrinter, self).__init__(fh)
self._model_fields_table = prettytable.PrettyTable(
['Field', 'Type', 'Restrictions', 'Required', 'Embedded', 'List'])
# The empty column was added as a patch, as rst does not accept
# single column tables
self._model_indexes_table = prettytable.PrettyTable(['Index', ''])
self._model_events_table = prettytable.PrettyTable(['Event', ''])
self._set_rst_tables_style()
self._model_printed = False
def _set_rst_tables_style(self):
for table in (self._model_fields_table,
self._model_indexes_table,
self._model_events_table):
table.horizontal_char = '='
table.vertical_char = ' '
table.junction_char = ' '
table.header_style = 'cap'
table.align = 'l'
def model_start(self, model_name):
# Print separator, anchor and title
if self._model_printed:
self._print('\n----\n')
_title = '{}'.format(model_name)
_surround_line = '-' * len(_title)
self._print(_surround_line)
self._print(_title)
self._print(_surround_line)
def model_end(self, model_name):
self._model_printed = True
def fields_end(self):
self._print(self._model_fields_table)
self._model_fields_table.clear_rows()
def handle_field(self, field_name, field_type, is_required, is_embedded,
is_single, restrictions):
restriction_str = '{}'.format(restrictions) if restrictions else ''
if field_type in BASIC_TYPES:
type_str = field_type
else:
type_str = '`{}`_'.format(field_type, field_type)
self._model_fields_table.add_row([field_name, type_str,
restriction_str, is_required,
is_embedded, not is_single])
def indexes_end(self):
self._print('\n|\n')
self._print(self._model_indexes_table)
self._model_indexes_table.clear_rows()
def handle_index(self, index_name):
self._model_indexes_table.add_row([index_name, ''])
def events_end(self):
self._print('')
self._print('|')
self._print('')
self._print(self._model_events_table)
self._model_events_table.clear_rows()
def handle_event(self, event_name):
self._model_events_table.add_row([event_name, ''])
class DfModelsParser(object):
"""Parser for the Dragonflow models schema
This parser iterates over the Dragonflow models by their dependency order
(models that others depend on will be before the dependant ones).
It uses a ModelsPrinter to actually print the information to the supported
formats.
"""
def __init__(self, printer):
"""Constructor for the DfModelsParser
It initializes the internal structures before the actual parsing
:param printer: instance of ModelsPrinter
:type printer: ModelsPrinter
"""
self._printer = printer
self._basic_types = BASIC_TYPES
self._processed_models = set()
self._all_models = set()
def _stringify_field_type(self, field):
if field in six.string_types:
return STRING_TYPE, None
elif isinstance(field, field_types.EnumField):
field_type = ENUM_TYPE
restrictions = list(field._valid_values)
return field_type, restrictions
elif isinstance(field, field_types.ReferenceField):
model = field._model
return model.__name__, None
elif isinstance(field, fields.StringField):
return STRING_TYPE, None
elif isinstance(field, fields.IntField):
return NUMBER_TYPE, None
elif isinstance(field, fields.FloatField):
return FLOAT_TYPE, None
elif isinstance(field, fields.BoolField):
return BOOL_TYPE, None
elif isinstance(field, fields.BaseField):
return STRING_TYPE, None
else:
return field.__name__, None
def _process_field(self, key, field):
if isinstance(field, field_types.ListOfField):
is_single = False
is_embedded = not isinstance(field,
field_types.ReferenceListField)
field_model = field.field
elif isinstance(field, fields.ListField):
is_single = False
is_embedded = False
field_model = field.items_types[0]
if isinstance(field, field_types.EnumListField):
restrictions = list(field._valid_values)
elif isinstance(field, fields.EmbeddedField):
is_single = True
is_embedded = True
field_model = field.types[0]
else:
is_single = True
is_embedded = False
field_model = field
field_type, restrictions = self._stringify_field_type(field_model)
if field_type not in self._basic_types:
if isinstance(field_model, field_types.ReferenceField):
model = field_model._model
else:
model = field_model
self._all_models.add(model)
# As we iterate over the models by their dependencies, if we did
# not encounter this model, it is an embedded model (type)
if model not in self._processed_models:
is_embedded = True
self._printer.handle_field(key, field_type, field.required,
is_embedded, is_single, restrictions)
def _process_fields(self, df_model):
self._printer.fields_start()
for key, field in df_model.iterate_over_fields():
self._process_field(key, field)
self._printer.fields_end()
def _process_indexes(self, df_model):
model_indexes = df_model.get_indexes()
if len(model_indexes) > 0:
self._printer.indexes_start()
for key in model_indexes:
self._printer.handle_index(key)
self._printer.indexes_end()
def _process_events(self, df_model):
model_events = df_model.get_events()
if len(model_events) > 0:
self._printer.events_start()
for event in model_events:
self._printer.handle_event(event)
self._printer.events_end()
def _process_model(self, df_model):
model_name = df_model.__name__
self._printer.model_start(model_name)
self._process_fields(df_model)
self._process_indexes(df_model)
self._process_events(df_model)
self._printer.model_end(model_name)
self._processed_models.add(df_model)
def _process_unvisited_model(self, model):
model_name = model.__name__
self._printer.model_start(model_name)
self._process_fields(model)
self._printer.model_end(model_name)
def parse_models(self):
"""Iterates over the models and processes them with the printer
This method iterates over all the models in the schema, sends them
for processing and then, makes sure the unknown models are also handled
"""
self._printer.output_start()
for model in model_framework.iter_models_by_dependency_order(False):
self._process_model(model)
# Handle unvisited models
remaining_models = self._all_models - self._processed_models
for model in remaining_models:
self._process_unvisited_model(model)
self._printer.output_end()
@contextlib.contextmanager
def smart_open(filename=None):
if filename and filename != '-':
fh = open(filename, 'w')
else:
fh = sys.stdout
try:
yield fh
finally:
if fh is not sys.stdout:
fh.close()
def main():
parser = argparse.ArgumentParser(description='Print Dragonflow schema')
group = parser.add_mutually_exclusive_group()
group.add_argument('-p', '--plaintext',
help='Plaintext output (default)',
action='store_true')
group.add_argument('-u', '--uml',
help='PlantUML format output',
action='store_true')
group.add_argument('-j', '--jsonschema',
help='OpenApiSchema JSON format output',
action='store_true')
group.add_argument('-r', '--rst',
help='reStructuredText output',
action='store_true')
parser.add_argument('-o', '--outfile',
help='Output to file (instead of stdout)')
args = parser.parse_args()
with smart_open(args.outfile) as fh:
if args.uml:
printer = UMLPrinter(fh)
elif args.json:
printer = OASPrinter(fh)
elif args.rst:
printer = RstPrinter(fh)
else:
printer = PlaintextPrinter(fh)
parser = DfModelsParser(printer)
parser.parse_models()
if __name__ == '__main__':
main()