1"""
2    :codeauthor: Pedro Algarvio (pedro@algarvio.me)
3    :codeauthor: Alexandru Bleotu (alexandru.bleotu@morganstanley.com)
4
5
6    salt.utils.schema
7    ~~~~~~~~~~~~~~~~~
8
9    Object Oriented Configuration - JSON Schema compatible generator
10
11    This code was inspired by `jsl`__, "A Python DSL for describing JSON
12    schemas".
13
14    .. __: https://jsl.readthedocs.io/
15
16
17    A configuration document or configuration document section is defined using
18    the py:class:`Schema`, the configuration items are defined by any of the
19    subclasses of py:class:`BaseSchemaItem` as attributes of a subclass of
20    py:class:`Schema` class.
21
22    A more complex configuration document (containing a defininitions section)
23    is defined using the py:class:`DefinitionsSchema`. This type of
24    schema supports having complex configuration items as attributes (defined
25    extending the py:class:`ComplexSchemaItem`). These items have other
26    configuration items (complex or not) as attributes, allowing to verify
27    more complex JSON data structures
28
29    As an example:
30
31    .. code-block:: python
32
33        class HostConfig(Schema):
34            title = 'Host Configuration'
35            description = 'This is the host configuration'
36
37            host = StringItem(
38                'Host',
39                'The looong host description',
40                default=None,
41                minimum=1
42            )
43
44            port = NumberItem(
45                description='The port number',
46                default=80,
47                required=False,
48                minimum=0,
49                inclusiveMinimum=False,
50                maximum=65535
51            )
52
53    The serialized version of the above configuration definition is:
54
55    .. code-block:: python
56
57        >>> print(HostConfig.serialize())
58        OrderedDict([
59            ('$schema', 'http://json-schema.org/draft-04/schema#'),
60            ('title', 'Host Configuration'),
61            ('description', 'This is the host configuration'),
62            ('type', 'object'),
63            ('properties', OrderedDict([
64                ('host', {'minimum': 1,
65                          'type': 'string',
66                          'description': 'The looong host description',
67                          'title': 'Host'}),
68                ('port', {'description': 'The port number',
69                          'default': 80,
70                          'inclusiveMinimum': False,
71                          'maximum': 65535,
72                          'minimum': 0,
73                          'type': 'number'})
74            ])),
75            ('required', ['host']),
76            ('x-ordering', ['host', 'port']),
77            ('additionalProperties', True)]
78        )
79        >>> print(salt.utils.json.dumps(HostConfig.serialize(), indent=2))
80        {
81            "$schema": "http://json-schema.org/draft-04/schema#",
82            "title": "Host Configuration",
83            "description": "This is the host configuration",
84            "type": "object",
85            "properties": {
86                "host": {
87                    "minimum": 1,
88                    "type": "string",
89                    "description": "The looong host description",
90                    "title": "Host"
91                },
92                "port": {
93                    "description": "The port number",
94                    "default": 80,
95                    "inclusiveMinimum": false,
96                    "maximum": 65535,
97                    "minimum": 0,
98                    "type": "number"
99                }
100            },
101            "required": [
102                "host"
103            ],
104            "x-ordering": [
105                "host",
106                "port"
107            ],
108            "additionalProperties": false
109        }
110
111
112    The serialized version of the configuration block can be used to validate a
113    configuration dictionary using the `python jsonschema library`__.
114
115    .. __: https://pypi.python.org/pypi/jsonschema
116
117    .. code-block:: python
118
119        >>> import jsonschema
120        >>> jsonschema.validate({'host': 'localhost', 'port': 80}, HostConfig.serialize())
121        >>> jsonschema.validate({'host': 'localhost', 'port': -1}, HostConfig.serialize())
122        Traceback (most recent call last):
123        File "<stdin>", line 1, in <module>
124        File "/usr/lib/python2.7/site-packages/jsonschema/validators.py", line 478, in validate
125            cls(schema, *args, **kwargs).validate(instance)
126        File "/usr/lib/python2.7/site-packages/jsonschema/validators.py", line 123, in validate
127            raise error
128        jsonschema.exceptions.ValidationError: -1 is less than the minimum of 0
129
130        Failed validating 'minimum' in schema['properties']['port']:
131            {'default': 80,
132            'description': 'The port number',
133            'inclusiveMinimum': False,
134            'maximum': 65535,
135            'minimum': 0,
136            'type': 'number'}
137
138        On instance['port']:
139            -1
140        >>>
141
142
143    A configuration document can even be split into configuration sections. Let's reuse the above
144    ``HostConfig`` class and include it in a configuration block:
145
146    .. code-block:: python
147
148        class LoggingConfig(Schema):
149            title = 'Logging Configuration'
150            description = 'This is the logging configuration'
151
152            log_level = StringItem(
153                'Logging Level',
154                'The logging level',
155                default='debug',
156                minimum=1
157            )
158
159        class MyConfig(Schema):
160
161            title = 'My Config'
162            description = 'This my configuration'
163
164            hostconfig = HostConfig()
165            logconfig = LoggingConfig()
166
167
168    The JSON Schema string version of the above is:
169
170    .. code-block:: python
171
172        >>> print salt.utils.json.dumps(MyConfig.serialize(), indent=4)
173        {
174            "$schema": "http://json-schema.org/draft-04/schema#",
175            "title": "My Config",
176            "description": "This my configuration",
177            "type": "object",
178            "properties": {
179                "hostconfig": {
180                    "id": "https://non-existing.saltstack.com/schemas/hostconfig.json#",
181                    "title": "Host Configuration",
182                    "description": "This is the host configuration",
183                    "type": "object",
184                    "properties": {
185                        "host": {
186                            "minimum": 1,
187                            "type": "string",
188                            "description": "The looong host description",
189                            "title": "Host"
190                        },
191                        "port": {
192                            "description": "The port number",
193                            "default": 80,
194                            "inclusiveMinimum": false,
195                            "maximum": 65535,
196                            "minimum": 0,
197                            "type": "number"
198                        }
199                    },
200                    "required": [
201                        "host"
202                    ],
203                    "x-ordering": [
204                        "host",
205                        "port"
206                    ],
207                    "additionalProperties": false
208                },
209                "logconfig": {
210                    "id": "https://non-existing.saltstack.com/schemas/logconfig.json#",
211                    "title": "Logging Configuration",
212                    "description": "This is the logging configuration",
213                    "type": "object",
214                    "properties": {
215                        "log_level": {
216                            "default": "debug",
217                            "minimum": 1,
218                            "type": "string",
219                            "description": "The logging level",
220                            "title": "Logging Level"
221                        }
222                    },
223                    "required": [
224                        "log_level"
225                    ],
226                    "x-ordering": [
227                        "log_level"
228                    ],
229                    "additionalProperties": false
230                }
231            },
232            "additionalProperties": false
233        }
234
235        >>> import jsonschema
236        >>> jsonschema.validate(
237            {'hostconfig': {'host': 'localhost', 'port': 80},
238             'logconfig': {'log_level': 'debug'}},
239            MyConfig.serialize())
240        >>> jsonschema.validate(
241            {'hostconfig': {'host': 'localhost', 'port': -1},
242             'logconfig': {'log_level': 'debug'}},
243            MyConfig.serialize())
244        Traceback (most recent call last):
245        File "<stdin>", line 1, in <module>
246        File "/usr/lib/python2.7/site-packages/jsonschema/validators.py", line 478, in validate
247            cls(schema, *args, **kwargs).validate(instance)
248        File "/usr/lib/python2.7/site-packages/jsonschema/validators.py", line 123, in validate
249            raise error
250        jsonschema.exceptions.ValidationError: -1 is less than the minimum of 0
251
252        Failed validating 'minimum' in schema['properties']['hostconfig']['properties']['port']:
253            {'default': 80,
254            'description': 'The port number',
255            'inclusiveMinimum': False,
256            'maximum': 65535,
257            'minimum': 0,
258            'type': 'number'}
259
260        On instance['hostconfig']['port']:
261            -1
262        >>>
263
264    If however, you just want to use the configuration blocks for readability
265    and do not desire the nested dictionaries serialization, you can pass
266    ``flatten=True`` when defining a configuration section as a configuration
267    subclass attribute:
268
269    .. code-block:: python
270
271        class MyConfig(Schema):
272
273            title = 'My Config'
274            description = 'This my configuration'
275
276            hostconfig = HostConfig(flatten=True)
277            logconfig = LoggingConfig(flatten=True)
278
279
280    The JSON Schema string version of the above is:
281
282    .. code-block:: python
283
284        >>> print(salt.utils.json.dumps(MyConfig, indent=4))
285        {
286            "$schema": "http://json-schema.org/draft-04/schema#",
287            "title": "My Config",
288            "description": "This my configuration",
289            "type": "object",
290            "properties": {
291                "host": {
292                    "minimum": 1,
293                    "type": "string",
294                    "description": "The looong host description",
295                    "title": "Host"
296                },
297                "port": {
298                    "description": "The port number",
299                    "default": 80,
300                    "inclusiveMinimum": false,
301                    "maximum": 65535,
302                    "minimum": 0,
303                    "type": "number"
304                },
305                "log_level": {
306                    "default": "debug",
307                    "minimum": 1,
308                    "type": "string",
309                    "description": "The logging level",
310                    "title": "Logging Level"
311                }
312            },
313            "x-ordering": [
314                "host",
315                "port",
316                "log_level"
317            ],
318            "additionalProperties": false
319        }
320"""
321
322import inspect
323import textwrap
324
325import salt.utils.args
326
327# import salt.utils.yaml
328from salt.utils.odict import OrderedDict
329
330BASE_SCHEMA_URL = "https://non-existing.saltstack.com/schemas"
331RENDER_COMMENT_YAML_MAX_LINE_LENGTH = 80
332
333
334class NullSentinel:
335    """
336    A class which instance represents a null value.
337    Allows specifying fields with a default value of null.
338    """
339
340    def __bool__(self):
341        return False
342
343    __nonzero__ = __bool__
344
345
346Null = NullSentinel()
347"""
348A special value that can be used to set the default value
349of a field to null.
350"""
351
352
353# make sure nobody creates another Null value
354def _failing_new(*args, **kwargs):
355    raise TypeError("Can't create another NullSentinel instance")
356
357
358NullSentinel.__new__ = staticmethod(_failing_new)
359del _failing_new
360
361
362class SchemaMeta(type):
363    @classmethod
364    def __prepare__(mcs, name, bases):
365        return OrderedDict()
366
367    def __new__(mcs, name, bases, attrs):
368        # Mark the instance as a configuration document/section
369        attrs["__config__"] = True
370        attrs["__flatten__"] = False
371        attrs["__config_name__"] = None
372
373        # Let's record the configuration items/sections
374        items = {}
375        sections = {}
376        order = []
377        # items from parent classes
378        for base in reversed(bases):
379            if hasattr(base, "_items"):
380                items.update(base._items)
381            if hasattr(base, "_sections"):
382                sections.update(base._sections)
383            if hasattr(base, "_order"):
384                order.extend(base._order)
385
386        # Iterate through attrs to discover items/config sections
387        for key, value in attrs.items():
388            entry_name = None
389            if not hasattr(value, "__item__") and not hasattr(value, "__config__"):
390                continue
391            if hasattr(value, "__item__"):
392                # the value is an item instance
393                if hasattr(value, "title") and value.title is None:
394                    # It's an item instance without a title, make the title
395                    # its name
396                    value.title = key
397                entry_name = value.__item_name__ or key
398                items[entry_name] = value
399            if hasattr(value, "__config__"):
400                entry_name = value.__config_name__ or key
401                sections[entry_name] = value
402            order.append(entry_name)
403
404        attrs["_order"] = order
405        attrs["_items"] = items
406        attrs["_sections"] = sections
407        return type.__new__(mcs, name, bases, attrs)
408
409    def __call__(cls, flatten=False, allow_additional_items=False, **kwargs):
410        instance = object.__new__(cls)
411        instance.__config_name__ = kwargs.pop("name", None)
412        if flatten is True:
413            # This configuration block is to be treated as a part of the
414            # configuration for which it was defined as an attribute, not as
415            # its own sub configuration
416            instance.__flatten__ = True
417        if allow_additional_items is True:
418            # The configuration block only accepts the configuration items
419            # which are defined on the class. On additional items, validation
420            # with jsonschema will fail
421            instance.__allow_additional_items__ = True
422        instance.__init__(**kwargs)
423        return instance
424
425
426class BaseSchemaItemMeta(type):
427    """
428    Config item metaclass to "tag" the class as a configuration item
429    """
430
431    @classmethod
432    def __prepare__(mcs, name, bases):
433        return OrderedDict()
434
435    def __new__(mcs, name, bases, attrs):
436        # Register the class as an item class
437        attrs["__item__"] = True
438        attrs["__item_name__"] = None
439        # Instantiate an empty list to store the config item attribute names
440        attributes = []
441        for base in reversed(bases):
442            try:
443                base_attributes = getattr(base, "_attributes", [])
444                if base_attributes:
445                    attributes.extend(base_attributes)
446                # Extend the attributes with the base argspec argument names
447                # but skip "self"
448                for argname in salt.utils.args.get_function_argspec(base.__init__).args:
449                    if argname == "self" or argname in attributes:
450                        continue
451                    if argname == "name":
452                        continue
453                    attributes.append(argname)
454            except TypeError:
455                # On the base object type, __init__ is just a wrapper which
456                # triggers a TypeError when we're trying to find out its
457                # argspec
458                continue
459        attrs["_attributes"] = attributes
460        return type.__new__(mcs, name, bases, attrs)
461
462    def __call__(cls, *args, **kwargs):
463        # Create the instance class
464        instance = object.__new__(cls)
465        if args:
466            raise RuntimeError(
467                "Please pass all arguments as named arguments. Un-named "
468                "arguments are not supported"
469            )
470        for key in kwargs.copy():
471            # Store the kwarg keys as the instance attributes for the
472            # serialization step
473            if key == "name":
474                # This is the item name to override the class attribute name
475                instance.__item_name__ = kwargs.pop(key)
476                continue
477            if key not in instance._attributes:
478                instance._attributes.append(key)
479        # Init the class
480        instance.__init__(*args, **kwargs)
481        # Validate the instance after initialization
482        for base in reversed(inspect.getmro(cls)):
483            validate_attributes = getattr(base, "__validate_attributes__", None)
484            if validate_attributes:
485                if (
486                    instance.__validate_attributes__.__func__.__code__
487                    is not validate_attributes.__code__
488                ):
489                    # The method was overridden, run base.__validate_attributes__ function
490                    base.__validate_attributes__(instance)
491        # Finally, run the instance __validate_attributes__ function
492        instance.__validate_attributes__()
493        # Return the initialized class
494        return instance
495
496
497class Schema(metaclass=SchemaMeta):
498    """
499    Configuration definition class
500    """
501
502    # Define some class level attributes to make PyLint happier
503    title = None
504    description = None
505    _items = _sections = _order = None
506    __flatten__ = False
507    __allow_additional_items__ = False
508
509    @classmethod
510    def serialize(cls, id_=None):
511        # The order matters
512        serialized = OrderedDict()
513        if id_ is not None:
514            # This is meant as a configuration section, sub json schema
515            serialized["id"] = "{}/{}.json#".format(BASE_SCHEMA_URL, id_)
516        else:
517            # Main configuration block, json schema
518            serialized["$schema"] = "http://json-schema.org/draft-04/schema#"
519        if cls.title is not None:
520            serialized["title"] = cls.title
521        if cls.description is not None:
522            if cls.description == cls.__doc__:
523                serialized["description"] = textwrap.dedent(cls.description).strip()
524            else:
525                serialized["description"] = cls.description
526
527        required = []
528        ordering = []
529        serialized["type"] = "object"
530        properties = OrderedDict()
531        cls.after_items_update = []
532        for name in cls._order:  # pylint: disable=E1133
533            skip_order = False
534            item_name = None
535            if name in cls._sections:  # pylint: disable=E1135
536                section = cls._sections[name]
537                serialized_section = section.serialize(
538                    None if section.__flatten__ is True else name
539                )
540                if section.__flatten__ is True:
541                    # Flatten the configuration section into the parent
542                    # configuration
543                    properties.update(serialized_section["properties"])
544                    if "x-ordering" in serialized_section:
545                        ordering.extend(serialized_section["x-ordering"])
546                    if "required" in serialized_section:
547                        required.extend(serialized_section["required"])
548                    if hasattr(section, "after_items_update"):
549                        cls.after_items_update.extend(section.after_items_update)
550                    skip_order = True
551                else:
552                    # Store it as a configuration section
553                    properties[name] = serialized_section
554
555            if name in cls._items:  # pylint: disable=E1135
556                config = cls._items[name]
557                item_name = config.__item_name__ or name
558                # Handle the configuration items defined in the class instance
559                if config.__flatten__ is True:
560                    serialized_config = config.serialize()
561                    cls.after_items_update.append(serialized_config)
562                    skip_order = True
563                else:
564                    properties[item_name] = config.serialize()
565
566                if config.required:
567                    # If it's a required item, add it to the required list
568                    required.append(item_name)
569
570            if skip_order is False:
571                # Store the order of the item
572                if item_name is not None:
573                    if item_name not in ordering:
574                        ordering.append(item_name)
575                else:
576                    if name not in ordering:
577                        ordering.append(name)
578
579        if properties:
580            serialized["properties"] = properties
581
582        # Update the serialized object with any items to include after properties.
583        # Do not overwrite properties already existing in the serialized dict.
584        if cls.after_items_update:
585            after_items_update = {}
586            for entry in cls.after_items_update:
587                for name, data in entry.items():
588                    if name in after_items_update:
589                        if isinstance(after_items_update[name], list):
590                            after_items_update[name].extend(data)
591                    else:
592                        after_items_update[name] = data
593            if after_items_update:
594                after_items_update.update(serialized)
595                serialized = after_items_update
596
597        if required:
598            # Only include required if not empty
599            serialized["required"] = required
600        if ordering:
601            # Only include ordering if not empty
602            serialized["x-ordering"] = ordering
603        serialized["additionalProperties"] = cls.__allow_additional_items__
604        return serialized
605
606    @classmethod
607    def defaults(cls):
608        serialized = cls.serialize()
609        defaults = {}
610        for name, details in serialized["properties"].items():
611            if "default" in details:
612                defaults[name] = details["default"]
613                continue
614            if "properties" in details:
615                for sname, sdetails in details["properties"].items():
616                    if "default" in sdetails:
617                        defaults.setdefault(name, {})[sname] = sdetails["default"]
618                continue
619        return defaults
620
621    @classmethod
622    def as_requirements_item(cls):
623        serialized_schema = cls.serialize()
624        required = serialized_schema.get("required", [])
625        for name in serialized_schema["properties"]:
626            if name not in required:
627                required.append(name)
628        return RequirementsItem(requirements=required)
629
630    # @classmethod
631    # def render_as_rst(cls):
632    #    '''
633    #    Render the configuration block as a restructured text string
634    #    '''
635    #    # TODO: Implement RST rendering
636    #    raise NotImplementedError
637
638    # @classmethod
639    # def render_as_yaml(cls):
640    #    '''
641    #    Render the configuration block as a parseable YAML string including comments
642    #    '''
643    #    # TODO: Implement YAML rendering
644    #    raise NotImplementedError
645
646
647class SchemaItem(metaclass=BaseSchemaItemMeta):
648    """
649    Base configuration items class.
650
651    All configurations must subclass it
652    """
653
654    # Define some class level attributes to make PyLint happier
655    __type__ = None
656    __format__ = None
657    _attributes = None
658    __flatten__ = False
659
660    __serialize_attr_aliases__ = None
661
662    required = False
663
664    def __init__(self, required=None, **extra):
665        """
666        :param required: If the configuration item is required. Defaults to ``False``.
667        """
668        if required is not None:
669            self.required = required
670        self.extra = extra
671
672    def __validate_attributes__(self):
673        """
674        Run any validation check you need the instance attributes.
675
676        ATTENTION:
677
678        Don't call the parent class when overriding this
679        method because it will just duplicate the executions. This class'es
680        metaclass will take care of that.
681        """
682        if self.required not in (True, False):
683            raise RuntimeError("'required' can only be True/False")
684
685    def _get_argname_value(self, argname):
686        """
687        Return the argname value looking up on all possible attributes
688        """
689        # Let's see if there's a private function to get the value
690        argvalue = getattr(self, "__get_{}__".format(argname), None)
691        if argvalue is not None and callable(argvalue):
692            argvalue = argvalue()  # pylint: disable=not-callable
693        if argvalue is None:
694            # Let's see if the value is defined as a public class variable
695            argvalue = getattr(self, argname, None)
696        if argvalue is None:
697            # Let's see if it's defined as a private class variable
698            argvalue = getattr(self, "__{}__".format(argname), None)
699        if argvalue is None:
700            # Let's look for it in the extra dictionary
701            argvalue = self.extra.get(argname, None)
702        return argvalue
703
704    def serialize(self):
705        """
706        Return a serializable form of the config instance
707        """
708        raise NotImplementedError
709
710
711class BaseSchemaItem(SchemaItem):
712    """
713    Base configuration items class.
714
715    All configurations must subclass it
716    """
717
718    # Let's define description as a class attribute, this will allow a custom configuration
719    # item to do something like:
720    #   class MyCustomConfig(StringItem):
721    #       '''
722    #       This is my custom config, blah, blah, blah
723    #       '''
724    #       description = __doc__
725    #
726    description = None
727    # The same for all other base arguments
728    title = None
729    default = None
730    enum = None
731    enumNames = None
732
733    def __init__(
734        self,
735        title=None,
736        description=None,
737        default=None,
738        enum=None,
739        enumNames=None,
740        **kwargs
741    ):
742        """
743        :param required:
744            If the configuration item is required. Defaults to ``False``.
745        :param title:
746            A short explanation about the purpose of the data described by this item.
747        :param description:
748            A detailed explanation about the purpose of the data described by this item.
749        :param default:
750            The default value for this configuration item. May be :data:`.Null` (a special value
751            to set the default value to null).
752        :param enum:
753            A list(list, tuple, set) of valid choices.
754        """
755        if title is not None:
756            self.title = title
757        if description is not None:
758            self.description = description
759        if default is not None:
760            self.default = default
761        if enum is not None:
762            self.enum = enum
763        if enumNames is not None:
764            self.enumNames = enumNames
765        super().__init__(**kwargs)
766
767    def __validate_attributes__(self):
768        if self.enum is not None:
769            if not isinstance(self.enum, (list, tuple, set)):
770                raise RuntimeError(
771                    "Only the 'list', 'tuple' and 'set' python types can be used "
772                    "to define 'enum'"
773                )
774            if not isinstance(self.enum, list):
775                self.enum = list(self.enum)
776        if self.enumNames is not None:
777            if not isinstance(self.enumNames, (list, tuple, set)):
778                raise RuntimeError(
779                    "Only the 'list', 'tuple' and 'set' python types can be used "
780                    "to define 'enumNames'"
781                )
782            if len(self.enum) != len(self.enumNames):
783                raise RuntimeError(
784                    "The size of 'enumNames' must match the size of 'enum'"
785                )
786            if not isinstance(self.enumNames, list):
787                self.enumNames = list(self.enumNames)
788
789    def serialize(self):
790        """
791        Return a serializable form of the config instance
792        """
793        serialized = {"type": self.__type__}
794        for argname in self._attributes:
795            if argname == "required":
796                # This is handled elsewhere
797                continue
798            argvalue = self._get_argname_value(argname)
799            if argvalue is not None:
800                if argvalue is Null:
801                    argvalue = None
802                # None values are not meant to be included in the
803                # serialization, since this is not None...
804                if (
805                    self.__serialize_attr_aliases__
806                    and argname in self.__serialize_attr_aliases__
807                ):
808                    argname = self.__serialize_attr_aliases__[argname]
809                serialized[argname] = argvalue
810        return serialized
811
812    def __get_description__(self):
813        if self.description is not None:
814            if self.description == self.__doc__:
815                return textwrap.dedent(self.description).strip()
816            return self.description
817
818    # def render_as_rst(self, name):
819    #    '''
820    #    Render the configuration item as a restructured text string
821    #    '''
822    #    # TODO: Implement YAML rendering
823    #    raise NotImplementedError
824
825    # def render_as_yaml(self, name):
826    #    '''
827    #    Render the configuration item as a parseable YAML string including comments
828    #    '''
829    #    # TODO: Include the item rules in the output, minimum, maximum, etc...
830    #    output = '# ----- '
831    #    output += self.title
832    #    output += ' '
833    #    output += '-' * (RENDER_COMMENT_YAML_MAX_LINE_LENGTH - 7 - len(self.title) - 2)
834    #    output += '>\n'
835    #    if self.description:
836    #        output += '\n'.join(textwrap.wrap(self.description,
837    #                                          width=RENDER_COMMENT_YAML_MAX_LINE_LENGTH,
838    #                                          initial_indent='# '))
839    #        output += '\n'
840    #        yamled_default_value = salt.utils.yaml.safe_dump(self.default, default_flow_style=False).split('\n...', 1)[0]
841    #        output += '# Default: {0}\n'.format(yamled_default_value)
842    #        output += '#{0}: {1}\n'.format(name, yamled_default_value)
843    #    output += '# <---- '
844    #    output += self.title
845    #    output += ' '
846    #    output += '-' * (RENDER_COMMENT_YAML_MAX_LINE_LENGTH - 7 - len(self.title) - 1)
847    #    return output + '\n'
848
849
850class NullItem(BaseSchemaItem):
851
852    __type__ = "null"
853
854
855class BooleanItem(BaseSchemaItem):
856    __type__ = "boolean"
857
858
859class StringItem(BaseSchemaItem):
860    """
861    A string configuration field
862    """
863
864    __type__ = "string"
865
866    __serialize_attr_aliases__ = {"min_length": "minLength", "max_length": "maxLength"}
867
868    format = None
869    pattern = None
870    min_length = None
871    max_length = None
872
873    def __init__(
874        self,
875        format=None,  # pylint: disable=redefined-builtin
876        pattern=None,
877        min_length=None,
878        max_length=None,
879        **kwargs
880    ):
881        """
882        :param required:
883            If the configuration item is required. Defaults to ``False``.
884        :param title:
885            A short explanation about the purpose of the data described by this item.
886        :param description:
887            A detailed explanation about the purpose of the data described by this item.
888        :param default:
889            The default value for this configuration item. May be :data:`.Null` (a special value
890            to set the default value to null).
891        :param enum:
892            A list(list, tuple, set) of valid choices.
893        :param format:
894            A semantic format of the string (for example, ``"date-time"``, ``"email"``, or ``"uri"``).
895        :param pattern:
896            A regular expression (ECMA 262) that a string value must match.
897        :param min_length:
898            The minimum length
899        :param max_length:
900            The maximum length
901        """
902        if format is not None:  # pylint: disable=redefined-builtin
903            self.format = format
904        if pattern is not None:
905            self.pattern = pattern
906        if min_length is not None:
907            self.min_length = min_length
908        if max_length is not None:
909            self.max_length = max_length
910        super().__init__(**kwargs)
911
912    def __validate_attributes__(self):
913        if self.format is None and self.__format__ is not None:
914            self.format = self.__format__
915
916
917class EMailItem(StringItem):
918    """
919    An internet email address, see `RFC 5322, section 3.4.1`__.
920
921    .. __: http://tools.ietf.org/html/rfc5322
922    """
923
924    __format__ = "email"
925
926
927class IPv4Item(StringItem):
928    """
929    An IPv4 address configuration field, according to dotted-quad ABNF syntax as defined in
930    `RFC 2673, section 3.2`__.
931
932    .. __: http://tools.ietf.org/html/rfc2673
933    """
934
935    __format__ = "ipv4"
936
937
938class IPv6Item(StringItem):
939    """
940    An IPv6 address configuration field, as defined in `RFC 2373, section 2.2`__.
941
942    .. __: http://tools.ietf.org/html/rfc2373
943    """
944
945    __format__ = "ipv6"
946
947
948class HostnameItem(StringItem):
949    """
950    An Internet host name configuration field, see `RFC 1034, section 3.1`__.
951
952    .. __: http://tools.ietf.org/html/rfc1034
953    """
954
955    __format__ = "hostname"
956
957
958class DateTimeItem(StringItem):
959    """
960    An ISO 8601 formatted date-time configuration field, as defined by `RFC 3339, section 5.6`__.
961
962    .. __: http://tools.ietf.org/html/rfc3339
963    """
964
965    __format__ = "date-time"
966
967
968class UriItem(StringItem):
969    """
970    A universal resource identifier (URI) configuration field, according to `RFC3986`__.
971
972    .. __: http://tools.ietf.org/html/rfc3986
973    """
974
975    __format__ = "uri"
976
977
978class SecretItem(StringItem):
979    """
980    A string configuration field containing a secret, for example, passwords, API keys, etc
981    """
982
983    __format__ = "secret"
984
985
986class NumberItem(BaseSchemaItem):
987
988    __type__ = "number"
989
990    __serialize_attr_aliases__ = {
991        "multiple_of": "multipleOf",
992        "exclusive_minimum": "exclusiveMinimum",
993        "exclusive_maximum": "exclusiveMaximum",
994    }
995
996    multiple_of = None
997    minimum = None
998    exclusive_minimum = None
999    maximum = None
1000    exclusive_maximum = None
1001
1002    def __init__(
1003        self,
1004        multiple_of=None,
1005        minimum=None,
1006        exclusive_minimum=None,
1007        maximum=None,
1008        exclusive_maximum=None,
1009        **kwargs
1010    ):
1011        """
1012        :param required:
1013            If the configuration item is required. Defaults to ``False``.
1014        :param title:
1015            A short explanation about the purpose of the data described by this item.
1016        :param description:
1017            A detailed explanation about the purpose of the data described by this item.
1018        :param default:
1019            The default value for this configuration item. May be :data:`.Null` (a special value
1020            to set the default value to null).
1021        :param enum:
1022            A list(list, tuple, set) of valid choices.
1023        :param multiple_of:
1024            A value must be a multiple of this factor.
1025        :param minimum:
1026            The minimum allowed value
1027        :param exclusive_minimum:
1028            Whether a value is allowed to be exactly equal to the minimum
1029        :param maximum:
1030            The maximum allowed value
1031        :param exclusive_maximum:
1032            Whether a value is allowed to be exactly equal to the maximum
1033        """
1034        if multiple_of is not None:
1035            self.multiple_of = multiple_of
1036        if minimum is not None:
1037            self.minimum = minimum
1038        if exclusive_minimum is not None:
1039            self.exclusive_minimum = exclusive_minimum
1040        if maximum is not None:
1041            self.maximum = maximum
1042        if exclusive_maximum is not None:
1043            self.exclusive_maximum = exclusive_maximum
1044        super().__init__(**kwargs)
1045
1046
1047class IntegerItem(NumberItem):
1048    __type__ = "integer"
1049
1050
1051class ArrayItem(BaseSchemaItem):
1052    __type__ = "array"
1053
1054    __serialize_attr_aliases__ = {
1055        "min_items": "minItems",
1056        "max_items": "maxItems",
1057        "unique_items": "uniqueItems",
1058        "additional_items": "additionalItems",
1059    }
1060
1061    items = None
1062    min_items = None
1063    max_items = None
1064    unique_items = None
1065    additional_items = None
1066
1067    def __init__(
1068        self,
1069        items=None,
1070        min_items=None,
1071        max_items=None,
1072        unique_items=None,
1073        additional_items=None,
1074        **kwargs
1075    ):
1076        """
1077        :param required:
1078            If the configuration item is required. Defaults to ``False``.
1079        :param title:
1080            A short explanation about the purpose of the data described by this item.
1081        :param description:
1082            A detailed explanation about the purpose of the data described by this item.
1083        :param default:
1084            The default value for this configuration item. May be :data:`.Null` (a special value
1085            to set the default value to null).
1086        :param enum:
1087            A list(list, tuple, set) of valid choices.
1088        :param items:
1089            Either of the following:
1090                * :class:`BaseSchemaItem` -- all items of the array must match the field schema;
1091                * a list or a tuple of :class:`fields <.BaseSchemaItem>` -- all items of the array must be
1092                  valid according to the field schema at the corresponding index (tuple typing);
1093        :param min_items:
1094            Minimum length of the array
1095        :param max_items:
1096            Maximum length of the array
1097        :param unique_items:
1098            Whether all the values in the array must be distinct.
1099        :param additional_items:
1100            If the value of ``items`` is a list or a tuple, and the array length is larger than
1101            the number of fields in ``items``, then the additional items are described
1102            by the :class:`.BaseField` passed using this argument.
1103        :type additional_items: bool or :class:`.BaseSchemaItem`
1104        """
1105        if items is not None:
1106            self.items = items
1107        if min_items is not None:
1108            self.min_items = min_items
1109        if max_items is not None:
1110            self.max_items = max_items
1111        if unique_items is not None:
1112            self.unique_items = unique_items
1113        if additional_items is not None:
1114            self.additional_items = additional_items
1115        super().__init__(**kwargs)
1116
1117    def __validate_attributes__(self):
1118        if not self.items and not self.additional_items:
1119            raise RuntimeError("One of items or additional_items must be passed.")
1120        if self.items is not None:
1121            if isinstance(self.items, (list, tuple)):
1122                for item in self.items:
1123                    if not isinstance(item, (Schema, SchemaItem)):
1124                        raise RuntimeError(
1125                            "All items passed in the item argument tuple/list must be "
1126                            "a subclass of Schema, SchemaItem or BaseSchemaItem, "
1127                            "not {}".format(type(item))
1128                        )
1129            elif not isinstance(self.items, (Schema, SchemaItem)):
1130                raise RuntimeError(
1131                    "The items argument passed must be a subclass of "
1132                    "Schema, SchemaItem or BaseSchemaItem, not "
1133                    "{}".format(type(self.items))
1134                )
1135
1136    def __get_items__(self):
1137        if isinstance(self.items, (Schema, SchemaItem)):
1138            # This is either a Schema or a Basetem, return it in its
1139            # serialized form
1140            return self.items.serialize()
1141        if isinstance(self.items, (tuple, list)):
1142            items = []
1143            for item in self.items:
1144                items.append(item.serialize())
1145            return items
1146
1147
1148class DictItem(BaseSchemaItem):
1149
1150    __type__ = "object"
1151
1152    __serialize_attr_aliases__ = {
1153        "min_properties": "minProperties",
1154        "max_properties": "maxProperties",
1155        "pattern_properties": "patternProperties",
1156        "additional_properties": "additionalProperties",
1157    }
1158
1159    properties = None
1160    pattern_properties = None
1161    additional_properties = None
1162    min_properties = None
1163    max_properties = None
1164
1165    def __init__(
1166        self,
1167        properties=None,
1168        pattern_properties=None,
1169        additional_properties=None,
1170        min_properties=None,
1171        max_properties=None,
1172        **kwargs
1173    ):
1174        """
1175        :param required:
1176            If the configuration item is required. Defaults to ``False``.
1177        :type required:
1178            boolean
1179        :param title:
1180            A short explanation about the purpose of the data described by this item.
1181        :type title:
1182            str
1183        :param description:
1184            A detailed explanation about the purpose of the data described by this item.
1185        :param default:
1186            The default value for this configuration item. May be :data:`.Null` (a special value
1187            to set the default value to null).
1188        :param enum:
1189            A list(list, tuple, set) of valid choices.
1190        :param properties:
1191            A dictionary containing fields
1192        :param pattern_properties:
1193            A dictionary whose keys are regular expressions (ECMA 262).
1194            Properties match against these regular expressions, and for any that match,
1195            the property is described by the corresponding field schema.
1196        :type pattern_properties: dict[str -> :class:`.Schema` or
1197                                       :class:`.SchemaItem` or :class:`.BaseSchemaItem`]
1198        :param additional_properties:
1199            Describes properties that are not described by the ``properties`` or ``pattern_properties``.
1200        :type additional_properties: bool or :class:`.Schema` or :class:`.SchemaItem`
1201                                     or :class:`.BaseSchemaItem`
1202        :param min_properties:
1203            A minimum number of properties.
1204        :type min_properties: int
1205        :param max_properties:
1206            A maximum number of properties
1207        :type max_properties: int
1208        """
1209        if properties is not None:
1210            self.properties = properties
1211        if pattern_properties is not None:
1212            self.pattern_properties = pattern_properties
1213        if additional_properties is not None:
1214            self.additional_properties = additional_properties
1215        if min_properties is not None:
1216            self.min_properties = min_properties
1217        if max_properties is not None:
1218            self.max_properties = max_properties
1219        super().__init__(**kwargs)
1220
1221    def __validate_attributes__(self):
1222        if (
1223            not self.properties
1224            and not self.pattern_properties
1225            and not self.additional_properties
1226        ):
1227            raise RuntimeError(
1228                "One of properties, pattern_properties or additional_properties must be"
1229                " passed"
1230            )
1231        if self.properties is not None:
1232            if not isinstance(self.properties, (Schema, dict)):
1233                raise RuntimeError(
1234                    "The passed properties must be passed as a dict or "
1235                    " a Schema not '{}'".format(type(self.properties))
1236                )
1237            if not isinstance(self.properties, Schema):
1238                for key, prop in self.properties.items():
1239                    if not isinstance(prop, (Schema, SchemaItem)):
1240                        raise RuntimeError(
1241                            "The passed property who's key is '{}' must be of type "
1242                            "Schema, SchemaItem or BaseSchemaItem, not "
1243                            "'{}'".format(key, type(prop))
1244                        )
1245        if self.pattern_properties is not None:
1246            if not isinstance(self.pattern_properties, dict):
1247                raise RuntimeError(
1248                    "The passed pattern_properties must be passed as a dict "
1249                    "not '{}'".format(type(self.pattern_properties))
1250                )
1251            for key, prop in self.pattern_properties.items():
1252                if not isinstance(prop, (Schema, SchemaItem)):
1253                    raise RuntimeError(
1254                        "The passed pattern_property who's key is '{}' must "
1255                        "be of type Schema, SchemaItem or BaseSchemaItem, "
1256                        "not '{}'".format(key, type(prop))
1257                    )
1258        if self.additional_properties is not None:
1259            if not isinstance(self.additional_properties, (bool, Schema, SchemaItem)):
1260                raise RuntimeError(
1261                    "The passed additional_properties must be of type bool, "
1262                    "Schema, SchemaItem or BaseSchemaItem, not '{}'".format(
1263                        type(self.pattern_properties)
1264                    )
1265                )
1266
1267    def __get_properties__(self):
1268        if self.properties is None:
1269            return
1270        if isinstance(self.properties, Schema):
1271            return self.properties.serialize()["properties"]
1272        properties = OrderedDict()
1273        for key, prop in self.properties.items():
1274            properties[key] = prop.serialize()
1275        return properties
1276
1277    def __get_pattern_properties__(self):
1278        if self.pattern_properties is None:
1279            return
1280        pattern_properties = OrderedDict()
1281        for key, prop in self.pattern_properties.items():
1282            pattern_properties[key] = prop.serialize()
1283        return pattern_properties
1284
1285    def __get_additional_properties__(self):
1286        if self.additional_properties is None:
1287            return
1288        if isinstance(self.additional_properties, bool):
1289            return self.additional_properties
1290        return self.additional_properties.serialize()
1291
1292    def __call__(self, flatten=False):
1293        self.__flatten__ = flatten
1294        return self
1295
1296    def serialize(self):
1297        result = super().serialize()
1298        required = []
1299        if self.properties is not None:
1300            if isinstance(self.properties, Schema):
1301                serialized = self.properties.serialize()
1302                if "required" in serialized:
1303                    required.extend(serialized["required"])
1304            else:
1305                for key, prop in self.properties.items():
1306                    if prop.required:
1307                        required.append(key)
1308        if required:
1309            result["required"] = required
1310        return result
1311
1312
1313class RequirementsItem(SchemaItem):
1314    __type__ = "object"
1315
1316    requirements = None
1317
1318    def __init__(self, requirements=None):
1319        if requirements is not None:
1320            self.requirements = requirements
1321        super().__init__()
1322
1323    def __validate_attributes__(self):
1324        if self.requirements is None:
1325            raise RuntimeError("The passed requirements must not be empty")
1326        if not isinstance(self.requirements, (SchemaItem, list, tuple, set)):
1327            raise RuntimeError(
1328                "The passed requirements must be passed as a list, tuple, "
1329                "set SchemaItem or BaseSchemaItem, not '{}'".format(self.requirements)
1330            )
1331
1332        if not isinstance(self.requirements, SchemaItem):
1333            if not isinstance(self.requirements, list):
1334                self.requirements = list(self.requirements)
1335
1336            for idx, item in enumerate(self.requirements):
1337                if not isinstance(item, ((str,), SchemaItem)):
1338                    raise RuntimeError(
1339                        "The passed requirement at the {} index must be of type "
1340                        "str or SchemaItem, not '{}'".format(idx, type(item))
1341                    )
1342
1343    def serialize(self):
1344        if isinstance(self.requirements, SchemaItem):
1345            requirements = self.requirements.serialize()
1346        else:
1347            requirements = []
1348            for requirement in self.requirements:
1349                if isinstance(requirement, SchemaItem):
1350                    requirements.append(requirement.serialize())
1351                    continue
1352                requirements.append(requirement)
1353        return {"required": requirements}
1354
1355
1356class OneOfItem(SchemaItem):
1357
1358    __type__ = "oneOf"
1359
1360    items = None
1361
1362    def __init__(self, items=None, required=None):
1363        if items is not None:
1364            self.items = items
1365        super().__init__(required=required)
1366
1367    def __validate_attributes__(self):
1368        if not self.items:
1369            raise RuntimeError("The passed items must not be empty")
1370        if not isinstance(self.items, (list, tuple)):
1371            raise RuntimeError(
1372                "The passed items must be passed as a list/tuple not '{}'".format(
1373                    type(self.items)
1374                )
1375            )
1376        for idx, item in enumerate(self.items):
1377            if not isinstance(item, (Schema, SchemaItem)):
1378                raise RuntimeError(
1379                    "The passed item at the {} index must be of type "
1380                    "Schema, SchemaItem or BaseSchemaItem, not "
1381                    "'{}'".format(idx, type(item))
1382                )
1383        if not isinstance(self.items, list):
1384            self.items = list(self.items)
1385
1386    def __call__(self, flatten=False):
1387        self.__flatten__ = flatten
1388        return self
1389
1390    def serialize(self):
1391        return {self.__type__: [i.serialize() for i in self.items]}
1392
1393
1394class AnyOfItem(OneOfItem):
1395
1396    __type__ = "anyOf"
1397
1398
1399class AllOfItem(OneOfItem):
1400
1401    __type__ = "allOf"
1402
1403
1404class NotItem(SchemaItem):
1405
1406    __type__ = "not"
1407
1408    item = None
1409
1410    def __init__(self, item=None):
1411        if item is not None:
1412            self.item = item
1413        super().__init__()
1414
1415    def __validate_attributes__(self):
1416        if not self.item:
1417            raise RuntimeError("An item must be passed")
1418        if not isinstance(self.item, (Schema, SchemaItem)):
1419            raise RuntimeError(
1420                "The passed item be of type Schema, SchemaItem or "
1421                "BaseSchemaItem, not '{}'".format(type(self.item))
1422            )
1423
1424    def serialize(self):
1425        return {self.__type__: self.item.serialize()}
1426
1427
1428# ----- Custom Preconfigured Configs -------------------------------------------------------------------------------->
1429class PortItem(IntegerItem):
1430    minimum = 0  # yes, 0 is a valid port number
1431    maximum = 65535
1432
1433
1434# <---- Custom Preconfigured Configs ---------------------------------------------------------------------------------
1435
1436
1437class ComplexSchemaItem(BaseSchemaItem):
1438    """
1439    .. versionadded:: 2016.11.0
1440
1441    Complex Schema Item
1442    """
1443
1444    # This attribute is populated by the metaclass, but pylint fails to see it
1445    # and assumes it's not an iterable
1446    _attributes = []
1447    _definition_name = None
1448
1449    def __init__(self, definition_name=None, required=None):
1450        super().__init__(required=required)
1451        self.__type__ = "object"
1452        self._definition_name = (
1453            definition_name if definition_name else self.__class__.__name__
1454        )
1455        # Schema attributes might have been added as class attributes so we
1456        # and they must be added to the _attributes attr
1457        self._add_missing_schema_attributes()
1458
1459    def _add_missing_schema_attributes(self):
1460        """
1461        Adds any missed schema attributes to the _attributes list
1462
1463        The attributes can be class attributes and they won't be
1464        included in the _attributes list automatically
1465        """
1466        for attr in [attr for attr in dir(self) if not attr.startswith("__")]:
1467            attr_val = getattr(self, attr)
1468            if (
1469                isinstance(getattr(self, attr), SchemaItem)
1470                and attr not in self._attributes
1471            ):
1472
1473                self._attributes.append(attr)
1474
1475    @property
1476    def definition_name(self):
1477        return self._definition_name
1478
1479    def serialize(self):
1480        """
1481        The serialization of the complex item is a pointer to the item
1482        definition
1483        """
1484        return {"$ref": "#/definitions/{}".format(self.definition_name)}
1485
1486    def get_definition(self):
1487        """Returns the definition of the complex item"""
1488
1489        serialized = super().serialize()
1490        # Adjust entries in the serialization
1491        del serialized["definition_name"]
1492        serialized["title"] = self.definition_name
1493
1494        properties = {}
1495        required_attr_names = []
1496
1497        for attr_name in self._attributes:
1498            attr = getattr(self, attr_name)
1499            if attr and isinstance(attr, BaseSchemaItem):
1500                # Remove the attribute entry added by the base serialization
1501                del serialized[attr_name]
1502                properties[attr_name] = attr.serialize()
1503                properties[attr_name]["type"] = attr.__type__
1504                if attr.required:
1505                    required_attr_names.append(attr_name)
1506        if serialized.get("properties") is None:
1507            serialized["properties"] = {}
1508        serialized["properties"].update(properties)
1509
1510        # Assign the required array
1511        if required_attr_names:
1512            serialized["required"] = required_attr_names
1513        return serialized
1514
1515    def get_complex_attrs(self):
1516        """Returns a dictionary of the complex attributes"""
1517        return [
1518            getattr(self, attr_name)
1519            for attr_name in self._attributes
1520            if isinstance(getattr(self, attr_name), ComplexSchemaItem)
1521        ]
1522
1523
1524class DefinitionsSchema(Schema):
1525    """
1526    .. versionadded:: 2016.11.0
1527
1528    JSON schema class that supports ComplexSchemaItem objects by adding
1529    a definitions section to the JSON schema, containing the item definitions.
1530
1531    All references to ComplexSchemaItems are built using schema inline
1532    dereferencing.
1533    """
1534
1535    @classmethod
1536    def serialize(cls, id_=None):
1537        # Get the initial serialization
1538        serialized = super().serialize(id_)
1539        complex_items = []
1540        # Augment the serializations with the definitions of all complex items
1541        aux_items = cls._items.values()
1542
1543        # Convert dict_view object to a list on Python 3
1544        aux_items = list(aux_items)
1545
1546        while aux_items:
1547            item = aux_items.pop(0)
1548            # Add complex attributes
1549            if isinstance(item, ComplexSchemaItem):
1550                complex_items.append(item)
1551                aux_items.extend(item.get_complex_attrs())
1552
1553            # Handle container items
1554            if isinstance(item, OneOfItem):
1555                aux_items.extend(item.items)
1556            elif isinstance(item, ArrayItem):
1557                aux_items.append(item.items)
1558            elif isinstance(item, DictItem):
1559                if item.properties:
1560                    aux_items.extend(item.properties.values())
1561                if item.additional_properties and isinstance(
1562                    item.additional_properties, SchemaItem
1563                ):
1564
1565                    aux_items.append(item.additional_properties)
1566
1567        definitions = OrderedDict()
1568        for config in complex_items:
1569            if isinstance(config, ComplexSchemaItem):
1570                definitions[config.definition_name] = config.get_definition()
1571        serialized["definitions"] = definitions
1572        return serialized
1573