saltstack/salt

View on GitHub
salt/utils/schema.py

Summary

Maintainability
F
1 wk
Test Coverage
# -*- coding: utf-8 -*-
'''
    :codeauthor: Pedro Algarvio (pedro@algarvio.me)
    :codeauthor: Alexandru Bleotu (alexandru.bleotu@morganstanley.com)


    salt.utils.schema
    ~~~~~~~~~~~~~~~~~

    Object Oriented Configuration - JSON Schema compatible generator

    This code was inspired by `jsl`__, "A Python DSL for describing JSON
    schemas".

    .. __: https://jsl.readthedocs.io/


    A configuration document or configuration document section is defined using
    the py:class:`Schema`, the configuration items are defined by any of the
    subclasses of py:class:`BaseSchemaItem` as attributes of a subclass of
    py:class:`Schema` class.

    A more complex configuration document (containing a defininitions section)
    is defined using the py:class:`DefinitionsSchema`. This type of
    schema supports having complex configuration items as attributes (defined
    extending the py:class:`ComplexSchemaItem`). These items have other
    configuration items (complex or not) as attributes, allowing to verify
    more complex JSON data structures

    As an example:

    .. code-block:: python

        class HostConfig(Schema):
            title = 'Host Configuration'
            description = 'This is the host configuration'

            host = StringItem(
                'Host',
                'The looong host description',
                default=None,
                minimum=1
            )

            port = NumberItem(
                description='The port number',
                default=80,
                required=False,
                minimum=0,
                inclusiveMinimum=False,
                maximum=65535
            )

    The serialized version of the above configuration definition is:

    .. code-block:: python

        >>> print(HostConfig.serialize())
        OrderedDict([
            ('$schema', 'http://json-schema.org/draft-04/schema#'),
            ('title', 'Host Configuration'),
            ('description', 'This is the host configuration'),
            ('type', 'object'),
            ('properties', OrderedDict([
                ('host', {'minimum': 1,
                          'type': 'string',
                          'description': 'The looong host description',
                          'title': 'Host'}),
                ('port', {'description': 'The port number',
                          'default': 80,
                          'inclusiveMinimum': False,
                          'maximum': 65535,
                          'minimum': 0,
                          'type': 'number'})
            ])),
            ('required', ['host']),
            ('x-ordering', ['host', 'port']),
            ('additionalProperties', True)]
        )
        >>> print(salt.utils.json.dumps(HostConfig.serialize(), indent=2))
        {
            "$schema": "http://json-schema.org/draft-04/schema#",
            "title": "Host Configuration",
            "description": "This is the host configuration",
            "type": "object",
            "properties": {
                "host": {
                    "minimum": 1,
                    "type": "string",
                    "description": "The looong host description",
                    "title": "Host"
                },
                "port": {
                    "description": "The port number",
                    "default": 80,
                    "inclusiveMinimum": false,
                    "maximum": 65535,
                    "minimum": 0,
                    "type": "number"
                }
            },
            "required": [
                "host"
            ],
            "x-ordering": [
                "host",
                "port"
            ],
            "additionalProperties": false
        }


    The serialized version of the configuration block can be used to validate a
    configuration dictionary using the `python jsonschema library`__.

    .. __: https://pypi.python.org/pypi/jsonschema

    .. code-block:: python

        >>> import jsonschema
        >>> jsonschema.validate({'host': 'localhost', 'port': 80}, HostConfig.serialize())
        >>> jsonschema.validate({'host': 'localhost', 'port': -1}, HostConfig.serialize())
        Traceback (most recent call last):
        File "<stdin>", line 1, in <module>
        File "/usr/lib/python2.7/site-packages/jsonschema/validators.py", line 478, in validate
            cls(schema, *args, **kwargs).validate(instance)
        File "/usr/lib/python2.7/site-packages/jsonschema/validators.py", line 123, in validate
            raise error
        jsonschema.exceptions.ValidationError: -1 is less than the minimum of 0

        Failed validating 'minimum' in schema['properties']['port']:
            {'default': 80,
            'description': 'The port number',
            'inclusiveMinimum': False,
            'maximum': 65535,
            'minimum': 0,
            'type': 'number'}

        On instance['port']:
            -1
        >>>


    A configuration document can even be split into configuration sections. Let's reuse the above
    ``HostConfig`` class and include it in a configuration block:

    .. code-block:: python

        class LoggingConfig(Schema):
            title = 'Logging Configuration'
            description = 'This is the logging configuration'

            log_level = StringItem(
                'Logging Level',
                'The logging level',
                default='debug',
                minimum=1
            )

        class MyConfig(Schema):

            title = 'My Config'
            description = 'This my configuration'

            hostconfig = HostConfig()
            logconfig = LoggingConfig()


    The JSON Schema string version of the above is:

    .. code-block:: python

        >>> print salt.utils.json.dumps(MyConfig.serialize(), indent=4)
        {
            "$schema": "http://json-schema.org/draft-04/schema#",
            "title": "My Config",
            "description": "This my configuration",
            "type": "object",
            "properties": {
                "hostconfig": {
                    "id": "https://non-existing.saltstack.com/schemas/hostconfig.json#",
                    "title": "Host Configuration",
                    "description": "This is the host configuration",
                    "type": "object",
                    "properties": {
                        "host": {
                            "minimum": 1,
                            "type": "string",
                            "description": "The looong host description",
                            "title": "Host"
                        },
                        "port": {
                            "description": "The port number",
                            "default": 80,
                            "inclusiveMinimum": false,
                            "maximum": 65535,
                            "minimum": 0,
                            "type": "number"
                        }
                    },
                    "required": [
                        "host"
                    ],
                    "x-ordering": [
                        "host",
                        "port"
                    ],
                    "additionalProperties": false
                },
                "logconfig": {
                    "id": "https://non-existing.saltstack.com/schemas/logconfig.json#",
                    "title": "Logging Configuration",
                    "description": "This is the logging configuration",
                    "type": "object",
                    "properties": {
                        "log_level": {
                            "default": "debug",
                            "minimum": 1,
                            "type": "string",
                            "description": "The logging level",
                            "title": "Logging Level"
                        }
                    },
                    "required": [
                        "log_level"
                    ],
                    "x-ordering": [
                        "log_level"
                    ],
                    "additionalProperties": false
                }
            },
            "additionalProperties": false
        }

        >>> import jsonschema
        >>> jsonschema.validate(
            {'hostconfig': {'host': 'localhost', 'port': 80},
             'logconfig': {'log_level': 'debug'}},
            MyConfig.serialize())
        >>> jsonschema.validate(
            {'hostconfig': {'host': 'localhost', 'port': -1},
             'logconfig': {'log_level': 'debug'}},
            MyConfig.serialize())
        Traceback (most recent call last):
        File "<stdin>", line 1, in <module>
        File "/usr/lib/python2.7/site-packages/jsonschema/validators.py", line 478, in validate
            cls(schema, *args, **kwargs).validate(instance)
        File "/usr/lib/python2.7/site-packages/jsonschema/validators.py", line 123, in validate
            raise error
        jsonschema.exceptions.ValidationError: -1 is less than the minimum of 0

        Failed validating 'minimum' in schema['properties']['hostconfig']['properties']['port']:
            {'default': 80,
            'description': 'The port number',
            'inclusiveMinimum': False,
            'maximum': 65535,
            'minimum': 0,
            'type': 'number'}

        On instance['hostconfig']['port']:
            -1
        >>>

    If however, you just want to use the configuration blocks for readability
    and do not desire the nested dictionaries serialization, you can pass
    ``flatten=True`` when defining a configuration section as a configuration
    subclass attribute:

    .. code-block:: python

        class MyConfig(Schema):

            title = 'My Config'
            description = 'This my configuration'

            hostconfig = HostConfig(flatten=True)
            logconfig = LoggingConfig(flatten=True)


    The JSON Schema string version of the above is:

    .. code-block:: python

        >>> print(salt.utils.json.dumps(MyConfig, indent=4))
        {
            "$schema": "http://json-schema.org/draft-04/schema#",
            "title": "My Config",
            "description": "This my configuration",
            "type": "object",
            "properties": {
                "host": {
                    "minimum": 1,
                    "type": "string",
                    "description": "The looong host description",
                    "title": "Host"
                },
                "port": {
                    "description": "The port number",
                    "default": 80,
                    "inclusiveMinimum": false,
                    "maximum": 65535,
                    "minimum": 0,
                    "type": "number"
                },
                "log_level": {
                    "default": "debug",
                    "minimum": 1,
                    "type": "string",
                    "description": "The logging level",
                    "title": "Logging Level"
                }
            },
            "x-ordering": [
                "host",
                "port",
                "log_level"
            ],
            "additionalProperties": false
        }
'''
# Import python libs
from __future__ import absolute_import, print_function, unicode_literals
import sys
import inspect
import textwrap
import functools

# Import salt libs
import salt.utils.args
#import salt.utils.yaml
from salt.utils.odict import OrderedDict

# Import 3rd-party libs
from salt.ext import six

BASE_SCHEMA_URL = 'https://non-existing.saltstack.com/schemas'
RENDER_COMMENT_YAML_MAX_LINE_LENGTH = 80


class Prepareable(type):
    '''
    Preserve attributes order for python 2.x
    '''
    # This code was taken from
    # https://github.com/aromanovich/jsl/blob/master/jsl/_compat/prepareable.py
    # which in turn was taken from https://gist.github.com/DasIch/5562625 with minor fixes
    if not six.PY3:
        def __new__(mcs, name, bases, attributes):
            try:
                constructor = attributes["__new__"]
            except KeyError:
                return type.__new__(mcs, name, bases, attributes)

            def preparing_constructor(mcs, name, bases, attributes):
                try:
                    mcs.__prepare__
                except AttributeError:
                    return constructor(mcs, name, bases, attributes)
                namespace = mcs.__prepare__(name, bases)
                defining_frame = sys._getframe(1)
                for constant in reversed(defining_frame.f_code.co_consts):
                    if inspect.iscode(constant) and constant.co_name == name:
                        def get_index(attribute_name, _names=constant.co_names):  # pylint: disable=cell-var-from-loop
                            try:
                                return _names.index(attribute_name)
                            except ValueError:
                                return 0
                        break
                else:
                    return constructor(mcs, name, bases, attributes)

                by_appearance = sorted(
                    attributes.items(), key=lambda item: get_index(item[0])
                )
                for key, value in by_appearance:
                    namespace[key] = value
                return constructor(mcs, name, bases, namespace)
            attributes["__new__"] = functools.wraps(constructor)(preparing_constructor)
            return type.__new__(mcs, name, bases, attributes)


class NullSentinel(object):
    '''
    A class which instance represents a null value.
    Allows specifying fields with a default value of null.
    '''

    def __bool__(self):
        return False

    __nonzero__ = __bool__


Null = NullSentinel()
'''
A special value that can be used to set the default value
of a field to null.
'''


# make sure nobody creates another Null value
def _failing_new(*args, **kwargs):
    raise TypeError('Can\'t create another NullSentinel instance')


NullSentinel.__new__ = staticmethod(_failing_new)
del _failing_new


class SchemaMeta(six.with_metaclass(Prepareable, type)):

    @classmethod
    def __prepare__(mcs, name, bases):
        return OrderedDict()

    def __new__(mcs, name, bases, attrs):
        # Mark the instance as a configuration document/section
        attrs['__config__'] = True
        attrs['__flatten__'] = False
        attrs['__config_name__'] = None

        # Let's record the configuration items/sections
        items = {}
        sections = {}
        order = []
        # items from parent classes
        for base in reversed(bases):
            if hasattr(base, '_items'):
                items.update(base._items)
            if hasattr(base, '_sections'):
                sections.update(base._sections)
            if hasattr(base, '_order'):
                order.extend(base._order)

        # Iterate through attrs to discover items/config sections
        for key, value in six.iteritems(attrs):
            entry_name = None
            if not hasattr(value, '__item__') and not hasattr(value, '__config__'):
                continue
            if hasattr(value, '__item__'):
                # the value is an item instance
                if hasattr(value, 'title') and value.title is None:
                    # It's an item instance without a title, make the title
                    # it's name
                    value.title = key
                entry_name = value.__item_name__ or key
                items[entry_name] = value
            if hasattr(value, '__config__'):
                entry_name = value.__config_name__ or key
                sections[entry_name] = value
            order.append(entry_name)

        attrs['_order'] = order
        attrs['_items'] = items
        attrs['_sections'] = sections
        return type.__new__(mcs, name, bases, attrs)

    def __call__(cls, flatten=False, allow_additional_items=False, **kwargs):
        instance = object.__new__(cls)
        instance.__config_name__ = kwargs.pop('name', None)
        if flatten is True:
            # This configuration block is to be treated as a part of the
            # configuration for which it was defined as an attribute, not as
            # it's own sub configuration
            instance.__flatten__ = True
        if allow_additional_items is True:
            # The configuration block only accepts the configuration items
            # which are defined on the class. On additional items, validation
            # with jsonschema will fail
            instance.__allow_additional_items__ = True
        instance.__init__(**kwargs)
        return instance


class BaseSchemaItemMeta(six.with_metaclass(Prepareable, type)):
    '''
    Config item metaclass to "tag" the class as a configuration item
    '''
    @classmethod
    def __prepare__(mcs, name, bases):
        return OrderedDict()

    def __new__(mcs, name, bases, attrs):
        # Register the class as an item class
        attrs['__item__'] = True
        attrs['__item_name__'] = None
        # Instantiate an empty list to store the config item attribute names
        attributes = []
        for base in reversed(bases):
            try:
                base_attributes = getattr(base, '_attributes', [])
                if base_attributes:
                    attributes.extend(base_attributes)
                # Extend the attributes with the base argspec argument names
                # but skip "self"
                for argname in salt.utils.args.get_function_argspec(base.__init__).args:
                    if argname == 'self' or argname in attributes:
                        continue
                    if argname == 'name':
                        continue
                    attributes.append(argname)
            except TypeError:
                # On the base object type, __init__ is just a wrapper which
                # triggers a TypeError when we're trying to find out it's
                # argspec
                continue
        attrs['_attributes'] = attributes
        return type.__new__(mcs, name, bases, attrs)

    def __call__(cls, *args, **kwargs):
        # Create the instance class
        instance = object.__new__(cls)
        if args:
            raise RuntimeError(
                'Please pass all arguments as named arguments. Un-named '
                'arguments are not supported'
            )
        for key in kwargs.copy():
            # Store the kwarg keys as the instance attributes for the
            # serialization step
            if key == 'name':
                # This is the item name to override the class attribute name
                instance.__item_name__ = kwargs.pop(key)
                continue
            if key not in instance._attributes:
                instance._attributes.append(key)
        # Init the class
        instance.__init__(*args, **kwargs)
        # Validate the instance after initialization
        for base in reversed(inspect.getmro(cls)):
            validate_attributes = getattr(base, '__validate_attributes__', None)
            if validate_attributes:
                if instance.__validate_attributes__.__func__.__code__ is not validate_attributes.__code__:
                    # The method was overridden, run base.__validate_attributes__ function
                    base.__validate_attributes__(instance)
        # Finally, run the instance __validate_attributes__ function
        instance.__validate_attributes__()
        # Return the initialized class
        return instance


class Schema(six.with_metaclass(SchemaMeta, object)):
    '''
    Configuration definition class
    '''

    # Define some class level attributes to make PyLint happier
    title = None
    description = None
    _items = _sections = _order = None
    __flatten__ = False
    __allow_additional_items__ = False

    @classmethod
    def serialize(cls, id_=None):
        # The order matters
        serialized = OrderedDict()
        if id_ is not None:
            # This is meant as a configuration section, sub json schema
            serialized['id'] = '{0}/{1}.json#'.format(BASE_SCHEMA_URL, id_)
        else:
            # Main configuration block, json schema
            serialized['$schema'] = 'http://json-schema.org/draft-04/schema#'
        if cls.title is not None:
            serialized['title'] = cls.title
        if cls.description is not None:
            if cls.description == cls.__doc__:
                serialized['description'] = textwrap.dedent(cls.description).strip()
            else:
                serialized['description'] = cls.description

        required = []
        ordering = []
        serialized['type'] = 'object'
        properties = OrderedDict()
        cls.after_items_update = []
        for name in cls._order:  # pylint: disable=E1133
            skip_order = False
            item_name = None
            if name in cls._sections:  # pylint: disable=E1135
                section = cls._sections[name]
                serialized_section = section.serialize(None if section.__flatten__ is True else name)
                if section.__flatten__ is True:
                    # Flatten the configuration section into the parent
                    # configuration
                    properties.update(serialized_section['properties'])
                    if 'x-ordering' in serialized_section:
                        ordering.extend(serialized_section['x-ordering'])
                    if 'required' in serialized_section:
                        required.extend(serialized_section['required'])
                    if hasattr(section, 'after_items_update'):
                        cls.after_items_update.extend(section.after_items_update)
                    skip_order = True
                else:
                    # Store it as a configuration section
                    properties[name] = serialized_section

            if name in cls._items:  # pylint: disable=E1135
                config = cls._items[name]
                item_name = config.__item_name__ or name
                # Handle the configuration items defined in the class instance
                if config.__flatten__ is True:
                    serialized_config = config.serialize()
                    cls.after_items_update.append(serialized_config)
                    skip_order = True
                else:
                    properties[item_name] = config.serialize()

                if config.required:
                    # If it's a required item, add it to the required list
                    required.append(item_name)

            if skip_order is False:
                # Store the order of the item
                if item_name is not None:
                    if item_name not in ordering:
                        ordering.append(item_name)
                else:
                    if name not in ordering:
                        ordering.append(name)

        if properties:
            serialized['properties'] = properties

        # Update the serialized object with any items to include after properties.
        # Do not overwrite properties already existing in the serialized dict.
        if cls.after_items_update:
            after_items_update = {}
            for entry in cls.after_items_update:
                for name, data in six.iteritems(entry):
                    if name in after_items_update:
                        if isinstance(after_items_update[name], list):
                            after_items_update[name].extend(data)
                    else:
                        after_items_update[name] = data
            if after_items_update:
                after_items_update.update(serialized)
                serialized = after_items_update

        if required:
            # Only include required if not empty
            serialized['required'] = required
        if ordering:
            # Only include ordering if not empty
            serialized['x-ordering'] = ordering
        serialized['additionalProperties'] = cls.__allow_additional_items__
        return serialized

    @classmethod
    def defaults(cls):
        serialized = cls.serialize()
        defaults = {}
        for name, details in serialized['properties'].items():
            if 'default' in details:
                defaults[name] = details['default']
                continue
            if 'properties' in details:
                for sname, sdetails in details['properties'].items():
                    if 'default' in sdetails:
                        defaults.setdefault(name, {})[sname] = sdetails['default']
                continue
        return defaults

    @classmethod
    def as_requirements_item(cls):
        serialized_schema = cls.serialize()
        required = serialized_schema.get('required', [])
        for name in serialized_schema['properties']:
            if name not in required:
                required.append(name)
        return RequirementsItem(requirements=required)

    #@classmethod
    #def render_as_rst(cls):
    #    '''
    #    Render the configuration block as a restructured text string
    #    '''
    #    # TODO: Implement RST rendering
    #    raise NotImplementedError

    #@classmethod
    #def render_as_yaml(cls):
    #    '''
    #    Render the configuration block as a parseable YAML string including comments
    #    '''
    #    # TODO: Implement YAML rendering
    #    raise NotImplementedError


class SchemaItem(six.with_metaclass(BaseSchemaItemMeta, object)):
    '''
    Base configuration items class.

    All configurations must subclass it
    '''

    # Define some class level attributes to make PyLint happier
    __type__ = None
    __format__ = None
    _attributes = None
    __flatten__ = False

    __serialize_attr_aliases__ = None

    required = False

    def __init__(self, required=None, **extra):
        '''
        :param required: If the configuration item is required. Defaults to ``False``.
        '''
        if required is not None:
            self.required = required
        self.extra = extra

    def __validate_attributes__(self):
        '''
        Run any validation check you need the instance attributes.

        ATTENTION:

        Don't call the parent class when overriding this
        method because it will just duplicate the executions. This class'es
        metaclass will take care of that.
        '''
        if self.required not in (True, False):
            raise RuntimeError(
                '\'required\' can only be True/False'
            )

    def _get_argname_value(self, argname):
        '''
        Return the argname value looking up on all possible attributes
        '''
        # Let's see if there's a private function to get the value
        argvalue = getattr(self, '__get_{0}__'.format(argname), None)
        if argvalue is not None and callable(argvalue):
            argvalue = argvalue()
        if argvalue is None:
            # Let's see if the value is defined as a public class variable
            argvalue = getattr(self, argname, None)
        if argvalue is None:
            # Let's see if it's defined as a private class variable
            argvalue = getattr(self, '__{0}__'.format(argname), None)
        if argvalue is None:
            # Let's look for it in the extra dictionary
            argvalue = self.extra.get(argname, None)
        return argvalue

    def serialize(self):
        '''
        Return a serializable form of the config instance
        '''
        raise NotImplementedError


class BaseSchemaItem(SchemaItem):
    '''
    Base configuration items class.

    All configurations must subclass it
    '''

    # Let's define description as a class attribute, this will allow a custom configuration
    # item to do something like:
    #   class MyCustomConfig(StringItem):
    #       '''
    #       This is my custom config, blah, blah, blah
    #       '''
    #       description = __doc__
    #
    description = None
    # The same for all other base arguments
    title = None
    default = None
    enum = None
    enumNames = None

    def __init__(self, title=None, description=None, default=None, enum=None, enumNames=None, **kwargs):
        '''
        :param required:
            If the configuration item is required. Defaults to ``False``.
        :param title:
            A short explanation about the purpose of the data described by this item.
        :param description:
            A detailed explanation about the purpose of the data described by this item.
        :param default:
            The default value for this configuration item. May be :data:`.Null` (a special value
            to set the default value to null).
        :param enum:
            A list(list, tuple, set) of valid choices.
        '''
        if title is not None:
            self.title = title
        if description is not None:
            self.description = description
        if default is not None:
            self.default = default
        if enum is not None:
            self.enum = enum
        if enumNames is not None:
            self.enumNames = enumNames
        super(BaseSchemaItem, self).__init__(**kwargs)

    def __validate_attributes__(self):
        if self.enum is not None:
            if not isinstance(self.enum, (list, tuple, set)):
                raise RuntimeError(
                    'Only the \'list\', \'tuple\' and \'set\' python types can be used '
                    'to define \'enum\''
                )
            if not isinstance(self.enum, list):
                self.enum = list(self.enum)
        if self.enumNames is not None:
            if not isinstance(self.enumNames, (list, tuple, set)):
                raise RuntimeError(
                    'Only the \'list\', \'tuple\' and \'set\' python types can be used '
                    'to define \'enumNames\''
                )
            if len(self.enum) != len(self.enumNames):
                raise RuntimeError(
                    'The size of \'enumNames\' must match the size of \'enum\''
                )
            if not isinstance(self.enumNames, list):
                self.enumNames = list(self.enumNames)

    def serialize(self):
        '''
        Return a serializable form of the config instance
        '''
        serialized = {'type': self.__type__}
        for argname in self._attributes:
            if argname == 'required':
                # This is handled elsewhere
                continue
            argvalue = self._get_argname_value(argname)
            if argvalue is not None:
                if argvalue is Null:
                    argvalue = None
                # None values are not meant to be included in the
                # serialization, since this is not None...
                if self.__serialize_attr_aliases__ and argname in self.__serialize_attr_aliases__:
                    argname = self.__serialize_attr_aliases__[argname]
                serialized[argname] = argvalue
        return serialized

    def __get_description__(self):
        if self.description is not None:
            if self.description == self.__doc__:
                return textwrap.dedent(self.description).strip()
            return self.description

    #def render_as_rst(self, name):
    #    '''
    #    Render the configuration item as a restructured text string
    #    '''
    #    # TODO: Implement YAML rendering
    #    raise NotImplementedError

    #def render_as_yaml(self, name):
    #    '''
    #    Render the configuration item as a parseable YAML string including comments
    #    '''
    #    # TODO: Include the item rules in the output, minimum, maximum, etc...
    #    output = '# ----- '
    #    output += self.title
    #    output += ' '
    #    output += '-' * (RENDER_COMMENT_YAML_MAX_LINE_LENGTH - 7 - len(self.title) - 2)
    #    output += '>\n'
    #    if self.description:
    #        output += '\n'.join(textwrap.wrap(self.description,
    #                                          width=RENDER_COMMENT_YAML_MAX_LINE_LENGTH,
    #                                          initial_indent='# '))
    #        output += '\n'
    #        yamled_default_value = salt.utils.yaml.safe_dump(self.default, default_flow_style=False).split('\n...', 1)[0]
    #        output += '# Default: {0}\n'.format(yamled_default_value)
    #        output += '#{0}: {1}\n'.format(name, yamled_default_value)
    #    output += '# <---- '
    #    output += self.title
    #    output += ' '
    #    output += '-' * (RENDER_COMMENT_YAML_MAX_LINE_LENGTH - 7 - len(self.title) - 1)
    #    return output + '\n'


class NullItem(BaseSchemaItem):

    __type__ = 'null'


class BooleanItem(BaseSchemaItem):
    __type__ = 'boolean'


class StringItem(BaseSchemaItem):
    '''
    A string configuration field
    '''

    __type__ = 'string'

    __serialize_attr_aliases__ = {
        'min_length': 'minLength',
        'max_length': 'maxLength'
    }

    format = None
    pattern = None
    min_length = None
    max_length = None

    def __init__(self,
                 format=None,  # pylint: disable=redefined-builtin
                 pattern=None,
                 min_length=None,
                 max_length=None,
                 **kwargs):
        '''
        :param required:
            If the configuration item is required. Defaults to ``False``.
        :param title:
            A short explanation about the purpose of the data described by this item.
        :param description:
            A detailed explanation about the purpose of the data described by this item.
        :param default:
            The default value for this configuration item. May be :data:`.Null` (a special value
            to set the default value to null).
        :param enum:
            A list(list, tuple, set) of valid choices.
        :param format:
            A semantic format of the string (for example, ``"date-time"``, ``"email"``, or ``"uri"``).
        :param pattern:
            A regular expression (ECMA 262) that a string value must match.
        :param min_length:
            The minimum length
        :param max_length:
            The maximum length
        '''
        if format is not None:  # pylint: disable=redefined-builtin
            self.format = format
        if pattern is not None:
            self.pattern = pattern
        if min_length is not None:
            self.min_length = min_length
        if max_length is not None:
            self.max_length = max_length
        super(StringItem, self).__init__(**kwargs)

    def __validate_attributes__(self):
        if self.format is None and self.__format__ is not None:
            self.format = self.__format__


class EMailItem(StringItem):
    '''
    An internet email address, see `RFC 5322, section 3.4.1`__.

    .. __: http://tools.ietf.org/html/rfc5322
    '''
    __format__ = 'email'


class IPv4Item(StringItem):
    '''
    An IPv4 address configuration field, according to dotted-quad ABNF syntax as defined in
    `RFC 2673, section 3.2`__.

    .. __: http://tools.ietf.org/html/rfc2673
    '''
    __format__ = 'ipv4'


class IPv6Item(StringItem):
    '''
    An IPv6 address configuration field, as defined in `RFC 2373, section 2.2`__.

    .. __: http://tools.ietf.org/html/rfc2373
    '''
    __format__ = 'ipv6'


class HostnameItem(StringItem):
    '''
    An Internet host name configuration field, see `RFC 1034, section 3.1`__.

    .. __: http://tools.ietf.org/html/rfc1034
    '''
    __format__ = 'hostname'


class DateTimeItem(StringItem):
    '''
    An ISO 8601 formatted date-time configuration field, as defined by `RFC 3339, section 5.6`__.

    .. __: http://tools.ietf.org/html/rfc3339
    '''
    __format__ = 'date-time'


class UriItem(StringItem):
    '''
    A universal resource identifier (URI) configuration field, according to `RFC3986`__.

    .. __: http://tools.ietf.org/html/rfc3986
    '''
    __format__ = 'uri'


class SecretItem(StringItem):
    '''
    A string configuration field containing a secret, for example, passwords, API keys, etc
    '''
    __format__ = 'secret'


class NumberItem(BaseSchemaItem):

    __type__ = 'number'

    __serialize_attr_aliases__ = {
        'multiple_of': 'multipleOf',
        'exclusive_minimum': 'exclusiveMinimum',
        'exclusive_maximum': 'exclusiveMaximum',
    }

    multiple_of = None
    minimum = None
    exclusive_minimum = None
    maximum = None
    exclusive_maximum = None

    def __init__(self,
                 multiple_of=None,
                 minimum=None,
                 exclusive_minimum=None,
                 maximum=None,
                 exclusive_maximum=None,
                 **kwargs):
        '''
        :param required:
            If the configuration item is required. Defaults to ``False``.
        :param title:
            A short explanation about the purpose of the data described by this item.
        :param description:
            A detailed explanation about the purpose of the data described by this item.
        :param default:
            The default value for this configuration item. May be :data:`.Null` (a special value
            to set the default value to null).
        :param enum:
            A list(list, tuple, set) of valid choices.
        :param multiple_of:
            A value must be a multiple of this factor.
        :param minimum:
            The minimum allowed value
        :param exclusive_minimum:
            Whether a value is allowed to be exactly equal to the minimum
        :param maximum:
            The maximum allowed value
        :param exclusive_maximum:
            Whether a value is allowed to be exactly equal to the maximum
        '''
        if multiple_of is not None:
            self.multiple_of = multiple_of
        if minimum is not None:
            self.minimum = minimum
        if exclusive_minimum is not None:
            self.exclusive_minimum = exclusive_minimum
        if maximum is not None:
            self.maximum = maximum
        if exclusive_maximum is not None:
            self.exclusive_maximum = exclusive_maximum
        super(NumberItem, self).__init__(**kwargs)


class IntegerItem(NumberItem):
    __type__ = 'integer'


class ArrayItem(BaseSchemaItem):
    __type__ = 'array'

    __serialize_attr_aliases__ = {
        'min_items': 'minItems',
        'max_items': 'maxItems',
        'unique_items': 'uniqueItems',
        'additional_items': 'additionalItems'
    }

    items = None
    min_items = None
    max_items = None
    unique_items = None
    additional_items = None

    def __init__(self,
                 items=None,
                 min_items=None,
                 max_items=None,
                 unique_items=None,
                 additional_items=None,
                 **kwargs):
        '''
        :param required:
            If the configuration item is required. Defaults to ``False``.
        :param title:
            A short explanation about the purpose of the data described by this item.
        :param description:
            A detailed explanation about the purpose of the data described by this item.
        :param default:
            The default value for this configuration item. May be :data:`.Null` (a special value
            to set the default value to null).
        :param enum:
            A list(list, tuple, set) of valid choices.
        :param items:
            Either of the following:
                * :class:`BaseSchemaItem` -- all items of the array must match the field schema;
                * a list or a tuple of :class:`fields <.BaseSchemaItem>` -- all items of the array must be
                  valid according to the field schema at the corresponding index (tuple typing);
        :param min_items:
            Minimum length of the array
        :param max_items:
            Maximum length of the array
        :param unique_items:
            Whether all the values in the array must be distinct.
        :param additional_items:
            If the value of ``items`` is a list or a tuple, and the array length is larger than
            the number of fields in ``items``, then the additional items are described
            by the :class:`.BaseField` passed using this argument.
        :type additional_items: bool or :class:`.BaseSchemaItem`
        '''
        if items is not None:
            self.items = items
        if min_items is not None:
            self.min_items = min_items
        if max_items is not None:
            self.max_items = max_items
        if unique_items is not None:
            self.unique_items = unique_items
        if additional_items is not None:
            self.additional_items = additional_items
        super(ArrayItem, self).__init__(**kwargs)

    def __validate_attributes__(self):
        if not self.items and not self.additional_items:
            raise RuntimeError(
                'One of items or additional_items must be passed.'
            )
        if self.items is not None:
            if isinstance(self.items, (list, tuple)):
                for item in self.items:
                    if not isinstance(item, (Schema, SchemaItem)):
                        raise RuntimeError(
                            'All items passed in the item argument tuple/list must be '
                            'a subclass of Schema, SchemaItem or BaseSchemaItem, '
                            'not {0}'.format(type(item))
                        )
            elif not isinstance(self.items, (Schema, SchemaItem)):
                raise RuntimeError(
                    'The items argument passed must be a subclass of '
                    'Schema, SchemaItem or BaseSchemaItem, not '
                    '{0}'.format(type(self.items))
                )

    def __get_items__(self):
        if isinstance(self.items, (Schema, SchemaItem)):
            # This is either a Schema or a Basetem, return it in it's
            # serialized form
            return self.items.serialize()
        if isinstance(self.items, (tuple, list)):
            items = []
            for item in self.items:
                items.append(item.serialize())
            return items


class DictItem(BaseSchemaItem):

    __type__ = 'object'

    __serialize_attr_aliases__ = {
        'min_properties': 'minProperties',
        'max_properties': 'maxProperties',
        'pattern_properties': 'patternProperties',
        'additional_properties': 'additionalProperties'
    }

    properties = None
    pattern_properties = None
    additional_properties = None
    min_properties = None
    max_properties = None

    def __init__(self,
                 properties=None,
                 pattern_properties=None,
                 additional_properties=None,
                 min_properties=None,
                 max_properties=None,
                 **kwargs):
        '''
        :param required:
            If the configuration item is required. Defaults to ``False``.
        :type required:
            boolean
        :param title:
            A short explanation about the purpose of the data described by this item.
        :type title:
            str
        :param description:
            A detailed explanation about the purpose of the data described by this item.
        :param default:
            The default value for this configuration item. May be :data:`.Null` (a special value
            to set the default value to null).
        :param enum:
            A list(list, tuple, set) of valid choices.
        :param properties:
            A dictionary containing fields
        :param pattern_properties:
            A dictionary whose keys are regular expressions (ECMA 262).
            Properties match against these regular expressions, and for any that match,
            the property is described by the corresponding field schema.
        :type pattern_properties: dict[str -> :class:`.Schema` or
                                       :class:`.SchemaItem` or :class:`.BaseSchemaItem`]
        :param additional_properties:
            Describes properties that are not described by the ``properties`` or ``pattern_properties``.
        :type additional_properties: bool or :class:`.Schema` or :class:`.SchemaItem`
                                     or :class:`.BaseSchemaItem`
        :param min_properties:
            A minimum number of properties.
        :type min_properties: int
        :param max_properties:
            A maximum number of properties
        :type max_properties: int
        '''
        if properties is not None:
            self.properties = properties
        if pattern_properties is not None:
            self.pattern_properties = pattern_properties
        if additional_properties is not None:
            self.additional_properties = additional_properties
        if min_properties is not None:
            self.min_properties = min_properties
        if max_properties is not None:
            self.max_properties = max_properties
        super(DictItem, self).__init__(**kwargs)

    def __validate_attributes__(self):
        if not self.properties and not self.pattern_properties and not self.additional_properties:
            raise RuntimeError(
                'One of properties, pattern_properties or additional_properties must be passed'
            )
        if self.properties is not None:
            if not isinstance(self.properties, (Schema, dict)):
                raise RuntimeError(
                    'The passed properties must be passed as a dict or '
                    ' a Schema not \'{0}\''.format(type(self.properties))
                )
            if not isinstance(self.properties, Schema):
                for key, prop in self.properties.items():
                    if not isinstance(prop, (Schema, SchemaItem)):
                        raise RuntimeError(
                            'The passed property who\'s key is \'{0}\' must be of type '
                            'Schema, SchemaItem or BaseSchemaItem, not '
                            '\'{1}\''.format(key, type(prop))
                        )
        if self.pattern_properties is not None:
            if not isinstance(self.pattern_properties, dict):
                raise RuntimeError(
                    'The passed pattern_properties must be passed as a dict '
                    'not \'{0}\''.format(type(self.pattern_properties))
                )
            for key, prop in self.pattern_properties.items():
                if not isinstance(prop, (Schema, SchemaItem)):
                    raise RuntimeError(
                        'The passed pattern_property who\'s key is \'{0}\' must '
                        'be of type Schema, SchemaItem or BaseSchemaItem, '
                        'not \'{1}\''.format(key, type(prop))
                    )
        if self.additional_properties is not None:
            if not isinstance(self.additional_properties, (bool, Schema, SchemaItem)):
                raise RuntimeError(
                    'The passed additional_properties must be of type bool, '
                    'Schema, SchemaItem or BaseSchemaItem, not \'{0}\''.format(
                        type(self.pattern_properties)
                    )
                )

    def __get_properties__(self):
        if self.properties is None:
            return
        if isinstance(self.properties, Schema):
            return self.properties.serialize()['properties']
        properties = OrderedDict()
        for key, prop in self.properties.items():
            properties[key] = prop.serialize()
        return properties

    def __get_pattern_properties__(self):
        if self.pattern_properties is None:
            return
        pattern_properties = OrderedDict()
        for key, prop in self.pattern_properties.items():
            pattern_properties[key] = prop.serialize()
        return pattern_properties

    def __get_additional_properties__(self):
        if self.additional_properties is None:
            return
        if isinstance(self.additional_properties, bool):
            return self.additional_properties
        return self.additional_properties.serialize()

    def __call__(self, flatten=False):
        self.__flatten__ = flatten
        return self

    def serialize(self):
        result = super(DictItem, self).serialize()
        required = []
        if self.properties is not None:
            if isinstance(self.properties, Schema):
                serialized = self.properties.serialize()
                if 'required' in serialized:
                    required.extend(serialized['required'])
            else:
                for key, prop in self.properties.items():
                    if prop.required:
                        required.append(key)
        if required:
            result['required'] = required
        return result


class RequirementsItem(SchemaItem):
    __type__ = 'object'

    requirements = None

    def __init__(self, requirements=None):
        if requirements is not None:
            self.requirements = requirements
        super(RequirementsItem, self).__init__()

    def __validate_attributes__(self):
        if self.requirements is None:
            raise RuntimeError(
                'The passed requirements must not be empty'
            )
        if not isinstance(self.requirements, (SchemaItem, list, tuple, set)):
            raise RuntimeError(
                'The passed requirements must be passed as a list, tuple, '
                'set SchemaItem or BaseSchemaItem, not \'{0}\''.format(self.requirements)
            )

        if not isinstance(self.requirements, SchemaItem):
            if not isinstance(self.requirements, list):
                self.requirements = list(self.requirements)

            for idx, item in enumerate(self.requirements):
                if not isinstance(item, (six.string_types, SchemaItem)):
                    raise RuntimeError(
                        'The passed requirement at the {0} index must be of type '
                        'str or SchemaItem, not \'{1}\''.format(idx, type(item))
                    )

    def serialize(self):
        if isinstance(self.requirements, SchemaItem):
            requirements = self.requirements.serialize()
        else:
            requirements = []
            for requirement in self.requirements:
                if isinstance(requirement, SchemaItem):
                    requirements.append(requirement.serialize())
                    continue
                requirements.append(requirement)
        return {'required': requirements}


class OneOfItem(SchemaItem):

    __type__ = 'oneOf'

    items = None

    def __init__(self, items=None, required=None):
        if items is not None:
            self.items = items
        super(OneOfItem, self).__init__(required=required)

    def __validate_attributes__(self):
        if not self.items:
            raise RuntimeError(
                'The passed items must not be empty'
            )
        if not isinstance(self.items, (list, tuple)):
            raise RuntimeError(
                'The passed items must be passed as a list/tuple not '
                '\'{0}\''.format(type(self.items))
            )
        for idx, item in enumerate(self.items):
            if not isinstance(item, (Schema, SchemaItem)):
                raise RuntimeError(
                    'The passed item at the {0} index must be of type '
                    'Schema, SchemaItem or BaseSchemaItem, not '
                    '\'{1}\''.format(idx, type(item))
                )
        if not isinstance(self.items, list):
            self.items = list(self.items)

    def __call__(self, flatten=False):
        self.__flatten__ = flatten
        return self

    def serialize(self):
        return {self.__type__: [i.serialize() for i in self.items]}


class AnyOfItem(OneOfItem):

    __type__ = 'anyOf'


class AllOfItem(OneOfItem):

    __type__ = 'allOf'


class NotItem(SchemaItem):

    __type__ = 'not'

    item = None

    def __init__(self, item=None):
        if item is not None:
            self.item = item
        super(NotItem, self).__init__()

    def __validate_attributes__(self):
        if not self.item:
            raise RuntimeError(
                'An item must be passed'
            )
        if not isinstance(self.item, (Schema, SchemaItem)):
            raise RuntimeError(
                'The passed item be of type Schema, SchemaItem or '
                'BaseSchemaItem, not \'{0}\''.format(type(self.item))
            )

    def serialize(self):
        return {self.__type__: self.item.serialize()}


# ----- Custom Preconfigured Configs -------------------------------------------------------------------------------->
class PortItem(IntegerItem):
    minimum = 0  # yes, 0 is a valid port number
    maximum = 65535
# <---- Custom Preconfigured Configs ---------------------------------------------------------------------------------


class ComplexSchemaItem(BaseSchemaItem):
    '''
    .. versionadded:: 2016.11.0

    Complex Schema Item
    '''

    # This attribute is populated by the metaclass, but pylint fails to see it
    # and assumes it's not an iterable
    _attributes = []
    _definition_name = None

    def __init__(self, definition_name=None, required=None):
        super(ComplexSchemaItem, self).__init__(required=required)
        self.__type__ = 'object'
        self._definition_name = definition_name if definition_name else \
                self.__class__.__name__
        # Schema attributes might have been added as class attributes so we
        # and they must be added to the _attributes attr
        self._add_missing_schema_attributes()

    def _add_missing_schema_attributes(self):
        '''
        Adds any missed schema attributes to the _attributes list

        The attributes can be class attributes and they won't be
        included in the _attributes list automatically
        '''
        for attr in [attr for attr in dir(self) if not attr.startswith('__')]:
            attr_val = getattr(self, attr)
            if isinstance(getattr(self, attr), SchemaItem) and \
               attr not in self._attributes:

                self._attributes.append(attr)

    @property
    def definition_name(self):
        return self._definition_name

    def serialize(self):
        '''
        The serialization of the complex item is a pointer to the item
        definition
        '''
        return {'$ref': '#/definitions/{0}'.format(self.definition_name)}

    def get_definition(self):
        '''Returns the definition of the complex item'''

        serialized = super(ComplexSchemaItem, self).serialize()
        # Adjust entries in the serialization
        del serialized['definition_name']
        serialized['title'] = self.definition_name

        properties = {}
        required_attr_names = []

        for attr_name in self._attributes:
            attr = getattr(self, attr_name)
            if attr and isinstance(attr, BaseSchemaItem):
                # Remove the attribute entry added by the base serialization
                del serialized[attr_name]
                properties[attr_name] = attr.serialize()
                properties[attr_name]['type'] = attr.__type__
                if attr.required:
                    required_attr_names.append(attr_name)
        if serialized.get('properties') is None:
            serialized['properties'] = {}
        serialized['properties'].update(properties)

        # Assign the required array
        if required_attr_names:
            serialized['required'] = required_attr_names
        return serialized

    def get_complex_attrs(self):
        '''Returns a dictionary of the complex attributes'''
        return [getattr(self, attr_name) for attr_name in self._attributes if
                isinstance(getattr(self, attr_name), ComplexSchemaItem)]


class DefinitionsSchema(Schema):
    '''
    .. versionadded:: 2016.11.0

    JSON schema class that supports ComplexSchemaItem objects by adding
    a definitions section to the JSON schema, containing the item definitions.

    All references to ComplexSchemaItems are built using schema inline
    dereferencing.
    '''

    @classmethod
    def serialize(cls, id_=None):
        # Get the initial serialization
        serialized = super(DefinitionsSchema, cls).serialize(id_)
        complex_items = []
        # Augment the serializations with the definitions of all complex items
        aux_items = cls._items.values()

        # Convert dict_view object to a list on Python 3
        if six.PY3:
            aux_items = list(aux_items)

        while aux_items:
            item = aux_items.pop(0)
            # Add complex attributes
            if isinstance(item, ComplexSchemaItem):
                complex_items.append(item)
                aux_items.extend(item.get_complex_attrs())

            # Handle container items
            if isinstance(item, OneOfItem):
                aux_items.extend(item.items)
            elif isinstance(item, ArrayItem):
                aux_items.append(item.items)
            elif isinstance(item, DictItem):
                if item.properties:
                    aux_items.extend(item.properties.values())
                if item.additional_properties and \
                   isinstance(item.additional_properties, SchemaItem):

                    aux_items.append(item.additional_properties)

        definitions = OrderedDict()
        for config in complex_items:
            if isinstance(config, ComplexSchemaItem):
                definitions[config.definition_name] = \
                        config.get_definition()
        serialized['definitions'] = definitions
        return serialized