generate.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import collections
  2. import io
  3. import os
  4. import re
  5. import ruamel.yaml
  6. import borgmatic.config.schema
  7. from borgmatic.config import load, normalize
  8. INDENT = 4
  9. SEQUENCE_INDENT = 2
  10. def insert_newline_before_comment(config, field_name):
  11. '''
  12. Using some ruamel.yaml black magic, insert a blank line in the config right before the given
  13. field and its comments.
  14. '''
  15. config.ca.items[field_name][1].insert(
  16. 0, ruamel.yaml.tokens.CommentToken('\n', ruamel.yaml.error.CommentMark(0), None)
  17. )
  18. SCALAR_SCHEMA_TYPES = {'string', 'boolean', 'integer', 'number'}
  19. def schema_to_sample_configuration(schema, source_config=None, level=0, parent_is_sequence=False):
  20. '''
  21. Given a loaded configuration schema and a source configuration, generate and return sample
  22. config for the schema. Include comments for each option based on the schema "description".
  23. If a source config is given, walk it alongside the given schema so that both can be taken into
  24. account when commenting out particular options in add_comments_to_configuration_object().
  25. '''
  26. schema_type = schema.get('type')
  27. example = schema.get('example')
  28. if schema_type == 'array' or (isinstance(schema_type, list) and 'array' in schema_type):
  29. config = ruamel.yaml.comments.CommentedSeq(
  30. [
  31. schema_to_sample_configuration(
  32. schema['items'], source_config, level, parent_is_sequence=True
  33. )
  34. ]
  35. )
  36. add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
  37. elif schema_type == 'object' or (isinstance(schema_type, list) and 'object' in schema_type):
  38. if source_config and isinstance(source_config, list) and isinstance(source_config[0], dict):
  39. source_config = dict(collections.ChainMap(*source_config))
  40. config = ruamel.yaml.comments.CommentedMap(
  41. [
  42. (
  43. field_name,
  44. sub_schema.get('example') if field_name == 'source_directories' else schema_to_sample_configuration(
  45. sub_schema, (source_config or {}).get(field_name, {}), level + 1
  46. ),
  47. )
  48. for field_name, sub_schema in borgmatic.config.schema.get_properties(schema).items()
  49. ]
  50. )
  51. indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
  52. add_comments_to_configuration_object(
  53. config, schema, source_config, indent=indent, skip_first=parent_is_sequence
  54. )
  55. elif isinstance(schema_type, list) and all(element_schema_type in SCALAR_SCHEMA_TYPES for element_schema_type in schema_type):
  56. return example
  57. elif schema_type in SCALAR_SCHEMA_TYPES:
  58. return example
  59. else:
  60. raise ValueError(f'Schema at level {level} is unsupported: {schema}')
  61. return config
  62. def comment_out_line(line):
  63. # If it's already is commented out (or empty), there's nothing further to do!
  64. stripped_line = line.lstrip()
  65. if not stripped_line or stripped_line.startswith('#'):
  66. return line
  67. # Comment out the names of optional options, inserting the '#' after any indent for aesthetics.
  68. matches = re.match(r'(\s*)', line)
  69. indent_spaces = matches.group(0) if matches else ''
  70. count_indent_spaces = len(indent_spaces)
  71. return '# '.join((indent_spaces, line[count_indent_spaces:]))
  72. def comment_out_optional_configuration(rendered_config):
  73. '''
  74. Post-process a rendered configuration string to comment out optional key/values, as determined
  75. by a sentinel in the comment before each key.
  76. The idea is that the pre-commented configuration prevents the user from having to comment out a
  77. bunch of configuration they don't care about to get to a minimal viable configuration file.
  78. Ideally ruamel.yaml would support commenting out keys during configuration generation, but it's
  79. not terribly easy to accomplish that way.
  80. '''
  81. lines = []
  82. optional = False
  83. for line in rendered_config.split('\n'):
  84. # Upon encountering an optional configuration option, comment out lines until the next blank
  85. # line.
  86. if line.strip().startswith(f'# {COMMENTED_OUT_SENTINEL}'):
  87. optional = True
  88. continue
  89. # Hit a blank line, so reset commenting.
  90. if not line.strip():
  91. optional = False
  92. lines.append(comment_out_line(line) if optional else line)
  93. return '\n'.join(lines)
  94. def render_configuration(config):
  95. '''
  96. Given a config data structure of nested OrderedDicts, render the config as YAML and return it.
  97. '''
  98. dumper = ruamel.yaml.YAML(typ='rt')
  99. dumper.indent(mapping=INDENT, sequence=INDENT + SEQUENCE_INDENT, offset=INDENT)
  100. rendered = io.StringIO()
  101. dumper.dump(config, rendered)
  102. return rendered.getvalue()
  103. def write_configuration(config_filename, rendered_config, mode=0o600, overwrite=False):
  104. '''
  105. Given a target config filename and rendered config YAML, write it out to file. Create any
  106. containing directories as needed. But if the file already exists and overwrite is False,
  107. abort before writing anything.
  108. '''
  109. if not overwrite and os.path.exists(config_filename):
  110. raise FileExistsError(
  111. f'{config_filename} already exists. Aborting. Use --overwrite to replace the file.'
  112. )
  113. try:
  114. os.makedirs(os.path.dirname(config_filename), mode=0o700)
  115. except (FileExistsError, FileNotFoundError):
  116. pass
  117. with open(config_filename, 'w') as config_file:
  118. config_file.write(rendered_config)
  119. os.chmod(config_filename, mode)
  120. def add_comments_to_configuration_sequence(config, schema, indent=0):
  121. '''
  122. If the given config sequence's items are object, then mine the schema for the description of the
  123. object's first item, and slap that atop the sequence. Indent the comment the given number of
  124. characters.
  125. Doing this for sequences of maps results in nice comments that look like:
  126. ```
  127. things:
  128. # First key description. Added by this function.
  129. - key: foo
  130. # Second key description. Added by add_comments_to_configuration_object().
  131. other: bar
  132. ```
  133. '''
  134. if schema['items'].get('type') != 'object':
  135. return
  136. for field_name in config[0].keys():
  137. field_schema = borgmatic.config.schema.get_properties(schema['items']).get(field_name, {})
  138. description = field_schema.get('description')
  139. # No description to use? Skip it.
  140. if not field_schema or not description:
  141. return
  142. config[0].yaml_set_start_comment(description, indent=indent)
  143. # We only want the first key's description here, as the rest of the keys get commented by
  144. # add_comments_to_configuration_object().
  145. return
  146. DEFAULT_KEYS = {'source_directories', 'repositories', 'keep_daily'}
  147. COMMENTED_OUT_SENTINEL = 'COMMENT_OUT'
  148. def add_comments_to_configuration_object(
  149. config, schema, source_config=None, indent=0, skip_first=False
  150. ):
  151. '''
  152. Using descriptions from a schema as a source, add those descriptions as comments to the given
  153. configuration dict, putting them before each field. Indent the comment the given number of
  154. characters.
  155. And a sentinel for commenting out options that are neither in DEFAULT_KEYS nor the the given
  156. source configuration dict. The idea is that any options used in the source configuration should
  157. stay active in the generated configuration.
  158. '''
  159. for index, field_name in enumerate(config.keys()):
  160. if skip_first and index == 0:
  161. continue
  162. field_schema = borgmatic.config.schema.get_properties(schema).get(field_name, {})
  163. description = field_schema.get('description', '').strip()
  164. # If this isn't a default key, add an indicator to the comment flagging it to be commented
  165. # out from the sample configuration. This sentinel is consumed by downstream processing that
  166. # does the actual commenting out.
  167. if field_name not in DEFAULT_KEYS and (
  168. source_config is None or field_name not in source_config
  169. ):
  170. description = (
  171. '\n'.join((description, COMMENTED_OUT_SENTINEL))
  172. if description
  173. else COMMENTED_OUT_SENTINEL
  174. )
  175. # No description to use? Skip it.
  176. if not field_schema or not description: # pragma: no cover
  177. continue
  178. config.yaml_set_comment_before_after_key(key=field_name, before=description, indent=indent)
  179. if index > 0:
  180. insert_newline_before_comment(config, field_name)
  181. RUAMEL_YAML_COMMENTS_INDEX = 1
  182. def merge_source_configuration_into_destination(destination_config, source_config):
  183. '''
  184. Deep merge the given source configuration dict into the destination configuration CommentedMap,
  185. favoring values from the source when there are collisions.
  186. The purpose of this is to upgrade configuration files from old versions of borgmatic by adding
  187. new configuration keys and comments.
  188. '''
  189. if not source_config:
  190. return destination_config
  191. if not destination_config or not isinstance(source_config, collections.abc.Mapping):
  192. return source_config
  193. for field_name, source_value in source_config.items():
  194. # This is a mapping. Recurse for this key/value.
  195. if isinstance(source_value, collections.abc.Mapping):
  196. destination_config[field_name] = merge_source_configuration_into_destination(
  197. destination_config[field_name], source_value
  198. )
  199. continue
  200. # This is a sequence. Recurse for each item in it.
  201. if isinstance(source_value, collections.abc.Sequence) and not isinstance(source_value, str):
  202. destination_value = destination_config[field_name]
  203. destination_config[field_name] = ruamel.yaml.comments.CommentedSeq(
  204. [
  205. merge_source_configuration_into_destination(
  206. destination_value[index] if index < len(destination_value) else None,
  207. source_item,
  208. )
  209. for index, source_item in enumerate(source_value)
  210. ]
  211. )
  212. continue
  213. # This is some sort of scalar. Set it into the destination.
  214. destination_config[field_name] = source_config[field_name]
  215. return destination_config
  216. def generate_sample_configuration(
  217. dry_run, source_filename, destination_filename, schema_filename, overwrite=False
  218. ):
  219. '''
  220. Given an optional source configuration filename, and a required destination configuration
  221. filename, the path to a schema filename in a YAML rendition of the JSON Schema format, and
  222. whether to overwrite a destination file, write out a sample configuration file based on that
  223. schema. If a source filename is provided, merge the parsed contents of that configuration into
  224. the generated configuration.
  225. '''
  226. schema = ruamel.yaml.YAML(typ='safe').load(open(schema_filename))
  227. source_config = None
  228. if source_filename:
  229. source_config = load.load_configuration(source_filename)
  230. normalize.normalize(source_filename, source_config)
  231. destination_config = merge_source_configuration_into_destination(
  232. schema_to_sample_configuration(schema, source_config), source_config
  233. )
  234. if dry_run:
  235. return
  236. write_configuration(
  237. destination_filename,
  238. comment_out_optional_configuration(render_configuration(destination_config)),
  239. overwrite=overwrite,
  240. )