2
0

generate.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import io
  2. import os
  3. import re
  4. from ruamel import yaml
  5. INDENT = 4
  6. SEQUENCE_INDENT = 2
  7. def _insert_newline_before_comment(config, field_name):
  8. '''
  9. Using some ruamel.yaml black magic, insert a blank line in the config right before the given
  10. field and its comments.
  11. '''
  12. config.ca.items[field_name][1].insert(
  13. 0, yaml.tokens.CommentToken('\n', yaml.error.CommentMark(0), None)
  14. )
  15. def _schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
  16. '''
  17. Given a loaded configuration schema, generate and return sample config for it. Include comments
  18. for each section based on the schema "desc" description.
  19. '''
  20. example = schema.get('example')
  21. if example is not None:
  22. return example
  23. if 'seq' in schema:
  24. config = yaml.comments.CommentedSeq(
  25. [
  26. _schema_to_sample_configuration(item_schema, level, parent_is_sequence=True)
  27. for item_schema in schema['seq']
  28. ]
  29. )
  30. add_comments_to_configuration_sequence(
  31. config, schema, indent=(level * INDENT) + SEQUENCE_INDENT
  32. )
  33. elif 'map' in schema:
  34. config = yaml.comments.CommentedMap(
  35. [
  36. (section_name, _schema_to_sample_configuration(section_schema, level + 1))
  37. for section_name, section_schema in schema['map'].items()
  38. ]
  39. )
  40. indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
  41. add_comments_to_configuration_map(
  42. config, schema, indent=indent, skip_first=parent_is_sequence
  43. )
  44. else:
  45. raise ValueError('Schema at level {} is unsupported: {}'.format(level, schema))
  46. return config
  47. def _comment_out_line(line):
  48. # If it's already is commented out (or empty), there's nothing further to do!
  49. stripped_line = line.lstrip()
  50. if not stripped_line or stripped_line.startswith('#'):
  51. return line
  52. # Comment out the names of optional sections, inserting the '#' after any indent for aesthetics.
  53. matches = re.match(r'(\s*)', line)
  54. indent_spaces = matches.group(0) if matches else ''
  55. count_indent_spaces = len(indent_spaces)
  56. return '# '.join((indent_spaces, line[count_indent_spaces:]))
  57. REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'}
  58. REQUIRED_SECTION_NAMES = {'location', 'retention'}
  59. def _comment_out_optional_configuration(rendered_config):
  60. '''
  61. Post-process a rendered configuration string to comment out optional key/values. The idea is
  62. that this prevents the user from having to comment out a bunch of configuration they don't care
  63. about to get to a minimal viable configuration file.
  64. Ideally ruamel.yaml would support this during configuration generation, but it's not terribly
  65. easy to accomplish that way.
  66. '''
  67. lines = []
  68. required = False
  69. for line in rendered_config.split('\n'):
  70. key = line.strip().split(':')[0]
  71. if key in REQUIRED_SECTION_NAMES:
  72. lines.append(line)
  73. continue
  74. # Upon encountering a required configuration option, skip commenting out lines until the
  75. # next blank line.
  76. if key in REQUIRED_KEYS:
  77. required = True
  78. elif not key:
  79. required = False
  80. lines.append(_comment_out_line(line) if not required else line)
  81. return '\n'.join(lines)
  82. def _render_configuration(config):
  83. '''
  84. Given a config data structure of nested OrderedDicts, render the config as YAML and return it.
  85. '''
  86. dumper = yaml.YAML()
  87. dumper.indent(mapping=INDENT, sequence=INDENT + SEQUENCE_INDENT, offset=INDENT)
  88. rendered = io.StringIO()
  89. dumper.dump(config, rendered)
  90. return rendered.getvalue()
  91. def write_configuration(config_filename, rendered_config, mode=0o600):
  92. '''
  93. Given a target config filename and rendered config YAML, write it out to file. Create any
  94. containing directories as needed.
  95. '''
  96. if os.path.exists(config_filename):
  97. raise FileExistsError('{} already exists. Aborting.'.format(config_filename))
  98. try:
  99. os.makedirs(os.path.dirname(config_filename), mode=0o700)
  100. except (FileExistsError, FileNotFoundError):
  101. pass
  102. with open(config_filename, 'w') as config_file:
  103. config_file.write(rendered_config)
  104. os.chmod(config_filename, mode)
  105. def add_comments_to_configuration_sequence(config, schema, indent=0):
  106. '''
  107. If the given config sequence's items are maps, then mine the schema for the description of the
  108. map's first item, and slap that atop the sequence. Indent the comment the given number of
  109. characters.
  110. Doing this for sequences of maps results in nice comments that look like:
  111. ```
  112. things:
  113. # First key description. Added by this function.
  114. - key: foo
  115. # Second key description. Added by add_comments_to_configuration_map().
  116. other: bar
  117. ```
  118. '''
  119. if 'map' not in schema['seq'][0]:
  120. return
  121. for field_name in config[0].keys():
  122. field_schema = schema['seq'][0]['map'].get(field_name, {})
  123. description = field_schema.get('desc')
  124. # No description to use? Skip it.
  125. if not field_schema or not description:
  126. return
  127. config[0].yaml_set_start_comment(description, indent=indent)
  128. # We only want the first key's description here, as the rest of the keys get commented by
  129. # add_comments_to_configuration_map().
  130. return
  131. def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False):
  132. '''
  133. Using descriptions from a schema as a source, add those descriptions as comments to the given
  134. config mapping, before each field. Indent the comment the given number of characters.
  135. '''
  136. for index, field_name in enumerate(config.keys()):
  137. if skip_first and index == 0:
  138. continue
  139. field_schema = schema['map'].get(field_name, {})
  140. description = field_schema.get('desc')
  141. # No description to use? Skip it.
  142. if not field_schema or not description:
  143. continue
  144. config.yaml_set_comment_before_after_key(key=field_name, before=description, indent=indent)
  145. if index > 0:
  146. _insert_newline_before_comment(config, field_name)
  147. def generate_sample_configuration(config_filename, schema_filename):
  148. '''
  149. Given a target config filename and the path to a schema filename in pykwalify YAML schema
  150. format, write out a sample configuration file based on that schema.
  151. '''
  152. schema = yaml.round_trip_load(open(schema_filename))
  153. config = _schema_to_sample_configuration(schema)
  154. write_configuration(
  155. config_filename, _comment_out_optional_configuration(_render_configuration(config))
  156. )