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