yedit.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. # flake8: noqa
  2. # pylint: disable=undefined-variable,missing-docstring
  3. # noqa: E301,E302
  4. class YeditException(Exception):
  5. ''' Exception class for Yedit '''
  6. pass
  7. # pylint: disable=too-many-public-methods
  8. class Yedit(object):
  9. ''' Class to modify yaml files '''
  10. re_valid_key = r"(((\[-?\d+\])|([0-9a-zA-Z%s/_-]+)).?)+$"
  11. re_key = r"(?:\[(-?\d+)\])|([0-9a-zA-Z%s/_-]+)"
  12. com_sep = set(['.', '#', '|', ':'])
  13. # pylint: disable=too-many-arguments
  14. def __init__(self,
  15. filename=None,
  16. content=None,
  17. content_type='yaml',
  18. separator='.',
  19. backup=False):
  20. self.content = content
  21. self._separator = separator
  22. self.filename = filename
  23. self.__yaml_dict = content
  24. self.content_type = content_type
  25. self.backup = backup
  26. self.load(content_type=self.content_type)
  27. if self.__yaml_dict is None:
  28. self.__yaml_dict = {}
  29. @property
  30. def separator(self):
  31. ''' getter method for yaml_dict '''
  32. return self._separator
  33. @separator.setter
  34. def separator(self):
  35. ''' getter method for yaml_dict '''
  36. return self._separator
  37. @property
  38. def yaml_dict(self):
  39. ''' getter method for yaml_dict '''
  40. return self.__yaml_dict
  41. @yaml_dict.setter
  42. def yaml_dict(self, value):
  43. ''' setter method for yaml_dict '''
  44. self.__yaml_dict = value
  45. @staticmethod
  46. def parse_key(key, sep='.'):
  47. '''parse the key allowing the appropriate separator'''
  48. common_separators = list(Yedit.com_sep - set([sep]))
  49. return re.findall(Yedit.re_key % ''.join(common_separators), key)
  50. @staticmethod
  51. def valid_key(key, sep='.'):
  52. '''validate the incoming key'''
  53. common_separators = list(Yedit.com_sep - set([sep]))
  54. if not re.match(Yedit.re_valid_key % ''.join(common_separators), key):
  55. return False
  56. return True
  57. @staticmethod
  58. def remove_entry(data, key, sep='.'):
  59. ''' remove data at location key '''
  60. if key == '' and isinstance(data, dict):
  61. data.clear()
  62. return True
  63. elif key == '' and isinstance(data, list):
  64. del data[:]
  65. return True
  66. if not (key and Yedit.valid_key(key, sep)) and \
  67. isinstance(data, (list, dict)):
  68. return None
  69. key_indexes = Yedit.parse_key(key, sep)
  70. for arr_ind, dict_key in key_indexes[:-1]:
  71. if dict_key and isinstance(data, dict):
  72. data = data.get(dict_key, None)
  73. elif (arr_ind and isinstance(data, list) and
  74. int(arr_ind) <= len(data) - 1):
  75. data = data[int(arr_ind)]
  76. else:
  77. return None
  78. # process last index for remove
  79. # expected list entry
  80. if key_indexes[-1][0]:
  81. if isinstance(data, list) and int(key_indexes[-1][0]) <= len(data) - 1: # noqa: E501
  82. del data[int(key_indexes[-1][0])]
  83. return True
  84. # expected dict entry
  85. elif key_indexes[-1][1]:
  86. if isinstance(data, dict):
  87. del data[key_indexes[-1][1]]
  88. return True
  89. @staticmethod
  90. def add_entry(data, key, item=None, sep='.'):
  91. ''' Get an item from a dictionary with key notation a.b.c
  92. d = {'a': {'b': 'c'}}}
  93. key = a#b
  94. return c
  95. '''
  96. if key == '':
  97. pass
  98. elif (not (key and Yedit.valid_key(key, sep)) and
  99. isinstance(data, (list, dict))):
  100. return None
  101. key_indexes = Yedit.parse_key(key, sep)
  102. for arr_ind, dict_key in key_indexes[:-1]:
  103. if dict_key:
  104. if isinstance(data, dict) and dict_key in data and data[dict_key]: # noqa: E501
  105. data = data[dict_key]
  106. continue
  107. elif data and not isinstance(data, dict):
  108. raise YeditException("Unexpected item type found while going through key " +
  109. "path: {} (at key: {})".format(key, dict_key))
  110. data[dict_key] = {}
  111. data = data[dict_key]
  112. elif (arr_ind and isinstance(data, list) and
  113. int(arr_ind) <= len(data) - 1):
  114. data = data[int(arr_ind)]
  115. else:
  116. raise YeditException("Unexpected item type found while going through key path: {}".format(key))
  117. if key == '':
  118. data = item
  119. # process last index for add
  120. # expected list entry
  121. elif key_indexes[-1][0] and isinstance(data, list) and int(key_indexes[-1][0]) <= len(data) - 1: # noqa: E501
  122. data[int(key_indexes[-1][0])] = item
  123. # expected dict entry
  124. elif key_indexes[-1][1] and isinstance(data, dict):
  125. data[key_indexes[-1][1]] = item
  126. # didn't add/update to an existing list, nor add/update key to a dict
  127. # so we must have been provided some syntax like a.b.c[<int>] = "data" for a
  128. # non-existent array
  129. else:
  130. raise YeditException("Error adding to object at path: {}".format(key))
  131. return data
  132. @staticmethod
  133. def get_entry(data, key, sep='.'):
  134. ''' Get an item from a dictionary with key notation a.b.c
  135. d = {'a': {'b': 'c'}}}
  136. key = a.b
  137. return c
  138. '''
  139. if key == '':
  140. pass
  141. elif (not (key and Yedit.valid_key(key, sep)) and
  142. isinstance(data, (list, dict))):
  143. return None
  144. key_indexes = Yedit.parse_key(key, sep)
  145. for arr_ind, dict_key in key_indexes:
  146. if dict_key and isinstance(data, dict):
  147. data = data.get(dict_key, None)
  148. elif (arr_ind and isinstance(data, list) and
  149. int(arr_ind) <= len(data) - 1):
  150. data = data[int(arr_ind)]
  151. else:
  152. return None
  153. return data
  154. @staticmethod
  155. def _write(filename, contents):
  156. ''' Actually write the file contents to disk. This helps with mocking. '''
  157. tmp_filename = filename + '.yedit'
  158. with open(tmp_filename, 'w') as yfd:
  159. yfd.write(contents)
  160. os.rename(tmp_filename, filename)
  161. def write(self):
  162. ''' write to file '''
  163. if not self.filename:
  164. raise YeditException('Please specify a filename.')
  165. if self.backup and self.file_exists():
  166. shutil.copy(self.filename, self.filename + '.orig')
  167. # Try to set format attributes if supported
  168. try:
  169. self.yaml_dict.fa.set_block_style()
  170. except AttributeError:
  171. pass
  172. # Try to use RoundTripDumper if supported.
  173. try:
  174. Yedit._write(self.filename, yaml.dump(self.yaml_dict, Dumper=yaml.RoundTripDumper))
  175. except AttributeError:
  176. Yedit._write(self.filename, yaml.safe_dump(self.yaml_dict, default_flow_style=False))
  177. return (True, self.yaml_dict)
  178. def read(self):
  179. ''' read from file '''
  180. # check if it exists
  181. if self.filename is None or not self.file_exists():
  182. return None
  183. contents = None
  184. with open(self.filename) as yfd:
  185. contents = yfd.read()
  186. return contents
  187. def file_exists(self):
  188. ''' return whether file exists '''
  189. if os.path.exists(self.filename):
  190. return True
  191. return False
  192. def load(self, content_type='yaml'):
  193. ''' return yaml file '''
  194. contents = self.read()
  195. if not contents and not self.content:
  196. return None
  197. if self.content:
  198. if isinstance(self.content, dict):
  199. self.yaml_dict = self.content
  200. return self.yaml_dict
  201. elif isinstance(self.content, str):
  202. contents = self.content
  203. # check if it is yaml
  204. try:
  205. if content_type == 'yaml' and contents:
  206. # Try to set format attributes if supported
  207. try:
  208. self.yaml_dict.fa.set_block_style()
  209. except AttributeError:
  210. pass
  211. # Try to use RoundTripLoader if supported.
  212. try:
  213. self.yaml_dict = yaml.safe_load(contents, yaml.RoundTripLoader)
  214. except AttributeError:
  215. self.yaml_dict = yaml.safe_load(contents)
  216. # Try to set format attributes if supported
  217. try:
  218. self.yaml_dict.fa.set_block_style()
  219. except AttributeError:
  220. pass
  221. elif content_type == 'json' and contents:
  222. self.yaml_dict = json.loads(contents)
  223. except yaml.YAMLError as err:
  224. # Error loading yaml or json
  225. raise YeditException('Problem with loading yaml file. %s' % err)
  226. return self.yaml_dict
  227. def get(self, key):
  228. ''' get a specified key'''
  229. try:
  230. entry = Yedit.get_entry(self.yaml_dict, key, self.separator)
  231. except KeyError:
  232. entry = None
  233. return entry
  234. def pop(self, path, key_or_item):
  235. ''' remove a key, value pair from a dict or an item for a list'''
  236. try:
  237. entry = Yedit.get_entry(self.yaml_dict, path, self.separator)
  238. except KeyError:
  239. entry = None
  240. if entry is None:
  241. return (False, self.yaml_dict)
  242. if isinstance(entry, dict):
  243. # AUDIT:maybe-no-member makes sense due to fuzzy types
  244. # pylint: disable=maybe-no-member
  245. if key_or_item in entry:
  246. entry.pop(key_or_item)
  247. return (True, self.yaml_dict)
  248. return (False, self.yaml_dict)
  249. elif isinstance(entry, list):
  250. # AUDIT:maybe-no-member makes sense due to fuzzy types
  251. # pylint: disable=maybe-no-member
  252. ind = None
  253. try:
  254. ind = entry.index(key_or_item)
  255. except ValueError:
  256. return (False, self.yaml_dict)
  257. entry.pop(ind)
  258. return (True, self.yaml_dict)
  259. return (False, self.yaml_dict)
  260. def delete(self, path):
  261. ''' remove path from a dict'''
  262. try:
  263. entry = Yedit.get_entry(self.yaml_dict, path, self.separator)
  264. except KeyError:
  265. entry = None
  266. if entry is None:
  267. return (False, self.yaml_dict)
  268. result = Yedit.remove_entry(self.yaml_dict, path, self.separator)
  269. if not result:
  270. return (False, self.yaml_dict)
  271. return (True, self.yaml_dict)
  272. def exists(self, path, value):
  273. ''' check if value exists at path'''
  274. try:
  275. entry = Yedit.get_entry(self.yaml_dict, path, self.separator)
  276. except KeyError:
  277. entry = None
  278. if isinstance(entry, list):
  279. if value in entry:
  280. return True
  281. return False
  282. elif isinstance(entry, dict):
  283. if isinstance(value, dict):
  284. rval = False
  285. for key, val in value.items():
  286. if entry[key] != val:
  287. rval = False
  288. break
  289. else:
  290. rval = True
  291. return rval
  292. return value in entry
  293. return entry == value
  294. def append(self, path, value):
  295. '''append value to a list'''
  296. try:
  297. entry = Yedit.get_entry(self.yaml_dict, path, self.separator)
  298. except KeyError:
  299. entry = None
  300. if entry is None:
  301. self.put(path, [])
  302. entry = Yedit.get_entry(self.yaml_dict, path, self.separator)
  303. if not isinstance(entry, list):
  304. return (False, self.yaml_dict)
  305. # AUDIT:maybe-no-member makes sense due to loading data from
  306. # a serialized format.
  307. # pylint: disable=maybe-no-member
  308. entry.append(value)
  309. return (True, self.yaml_dict)
  310. # pylint: disable=too-many-arguments
  311. def update(self, path, value, index=None, curr_value=None):
  312. ''' put path, value into a dict '''
  313. try:
  314. entry = Yedit.get_entry(self.yaml_dict, path, self.separator)
  315. except KeyError:
  316. entry = None
  317. if isinstance(entry, dict):
  318. # AUDIT:maybe-no-member makes sense due to fuzzy types
  319. # pylint: disable=maybe-no-member
  320. if not isinstance(value, dict):
  321. raise YeditException('Cannot replace key, value entry in ' +
  322. 'dict with non-dict type. value=[%s] [%s]' % (value, type(value))) # noqa: E501
  323. entry.update(value)
  324. return (True, self.yaml_dict)
  325. elif isinstance(entry, list):
  326. # AUDIT:maybe-no-member makes sense due to fuzzy types
  327. # pylint: disable=maybe-no-member
  328. ind = None
  329. if curr_value:
  330. try:
  331. ind = entry.index(curr_value)
  332. except ValueError:
  333. return (False, self.yaml_dict)
  334. elif index is not None:
  335. ind = index
  336. if ind is not None and entry[ind] != value:
  337. entry[ind] = value
  338. return (True, self.yaml_dict)
  339. # see if it exists in the list
  340. try:
  341. ind = entry.index(value)
  342. except ValueError:
  343. # doesn't exist, append it
  344. entry.append(value)
  345. return (True, self.yaml_dict)
  346. # already exists, return
  347. if ind is not None:
  348. return (False, self.yaml_dict)
  349. return (False, self.yaml_dict)
  350. def put(self, path, value):
  351. ''' put path, value into a dict '''
  352. try:
  353. entry = Yedit.get_entry(self.yaml_dict, path, self.separator)
  354. except KeyError:
  355. entry = None
  356. if entry == value:
  357. return (False, self.yaml_dict)
  358. # deepcopy didn't work
  359. # Try to use ruamel.yaml and fallback to pyyaml
  360. try:
  361. tmp_copy = yaml.load(yaml.round_trip_dump(self.yaml_dict,
  362. default_flow_style=False),
  363. yaml.RoundTripLoader)
  364. except AttributeError:
  365. tmp_copy = copy.deepcopy(self.yaml_dict)
  366. # set the format attributes if available
  367. try:
  368. tmp_copy.fa.set_block_style()
  369. except AttributeError:
  370. pass
  371. result = Yedit.add_entry(tmp_copy, path, value, self.separator)
  372. if not result:
  373. return (False, self.yaml_dict)
  374. self.yaml_dict = tmp_copy
  375. return (True, self.yaml_dict)
  376. def create(self, path, value):
  377. ''' create a yaml file '''
  378. if not self.file_exists():
  379. # deepcopy didn't work
  380. # Try to use ruamel.yaml and fallback to pyyaml
  381. try:
  382. tmp_copy = yaml.load(yaml.round_trip_dump(self.yaml_dict,
  383. default_flow_style=False),
  384. yaml.RoundTripLoader)
  385. except AttributeError:
  386. tmp_copy = copy.deepcopy(self.yaml_dict)
  387. # set the format attributes if available
  388. try:
  389. tmp_copy.fa.set_block_style()
  390. except AttributeError:
  391. pass
  392. result = Yedit.add_entry(tmp_copy, path, value, self.separator)
  393. if result:
  394. self.yaml_dict = tmp_copy
  395. return (True, self.yaml_dict)
  396. return (False, self.yaml_dict)
  397. @staticmethod
  398. def get_curr_value(invalue, val_type):
  399. '''return the current value'''
  400. if invalue is None:
  401. return None
  402. curr_value = invalue
  403. if val_type == 'yaml':
  404. curr_value = yaml.load(invalue)
  405. elif val_type == 'json':
  406. curr_value = json.loads(invalue)
  407. return curr_value
  408. @staticmethod
  409. def parse_value(inc_value, vtype=''):
  410. '''determine value type passed'''
  411. true_bools = ['y', 'Y', 'yes', 'Yes', 'YES', 'true', 'True', 'TRUE',
  412. 'on', 'On', 'ON', ]
  413. false_bools = ['n', 'N', 'no', 'No', 'NO', 'false', 'False', 'FALSE',
  414. 'off', 'Off', 'OFF']
  415. # It came in as a string but you didn't specify value_type as string
  416. # we will convert to bool if it matches any of the above cases
  417. if isinstance(inc_value, str) and 'bool' in vtype:
  418. if inc_value not in true_bools and inc_value not in false_bools:
  419. raise YeditException('Not a boolean type. str=[%s] vtype=[%s]' % (inc_value, vtype))
  420. elif isinstance(inc_value, bool) and 'str' in vtype:
  421. inc_value = str(inc_value)
  422. # There is a special case where '' will turn into None after yaml loading it so skip
  423. if isinstance(inc_value, str) and inc_value == '':
  424. pass
  425. # If vtype is not str then go ahead and attempt to yaml load it.
  426. elif isinstance(inc_value, str) and 'str' not in vtype:
  427. try:
  428. inc_value = yaml.load(inc_value)
  429. except Exception:
  430. raise YeditException('Could not determine type of incoming ' +
  431. 'value. value=[%s] vtype=[%s]'
  432. % (type(inc_value), vtype))
  433. return inc_value
  434. @staticmethod
  435. def process_edits(edits, yamlfile):
  436. '''run through a list of edits and process them one-by-one'''
  437. results = []
  438. for edit in edits:
  439. value = Yedit.parse_value(edit['value'], edit.get('value_type', ''))
  440. if 'action' in edit and edit['action'] == 'update':
  441. # pylint: disable=line-too-long
  442. curr_value = Yedit.get_curr_value(Yedit.parse_value(edit.get('curr_value', None)), # noqa: E501
  443. edit.get('curr_value_format', None)) # noqa: E501
  444. rval = yamlfile.update(edit['key'],
  445. value,
  446. edit.get('index', None),
  447. curr_value)
  448. elif 'action' in edit and edit['action'] == 'append':
  449. rval = yamlfile.append(edit['key'], value)
  450. else:
  451. rval = yamlfile.put(edit['key'], value)
  452. if rval[0]:
  453. results.append({'key': edit['key'], 'edit': rval[1]})
  454. return {'changed': len(results) > 0, 'results': results}
  455. # pylint: disable=too-many-return-statements,too-many-branches
  456. @staticmethod
  457. def run_ansible(params):
  458. '''perform the idempotent crud operations'''
  459. yamlfile = Yedit(filename=params['src'],
  460. backup=params['backup'],
  461. separator=params['separator'])
  462. state = params['state']
  463. if params['src']:
  464. rval = yamlfile.load()
  465. if yamlfile.yaml_dict is None and state != 'present':
  466. return {'failed': True,
  467. 'msg': 'Error opening file [%s]. Verify that the ' +
  468. 'file exists, that it is has correct' +
  469. ' permissions, and is valid yaml.'}
  470. if state == 'list':
  471. if params['content']:
  472. content = Yedit.parse_value(params['content'], params['content_type'])
  473. yamlfile.yaml_dict = content
  474. if params['key']:
  475. rval = yamlfile.get(params['key']) or {}
  476. return {'changed': False, 'result': rval, 'state': state}
  477. elif state == 'absent':
  478. if params['content']:
  479. content = Yedit.parse_value(params['content'], params['content_type'])
  480. yamlfile.yaml_dict = content
  481. if params['update']:
  482. rval = yamlfile.pop(params['key'], params['value'])
  483. else:
  484. rval = yamlfile.delete(params['key'])
  485. if rval[0] and params['src']:
  486. yamlfile.write()
  487. return {'changed': rval[0], 'result': rval[1], 'state': state}
  488. elif state == 'present':
  489. # check if content is different than what is in the file
  490. if params['content']:
  491. content = Yedit.parse_value(params['content'], params['content_type'])
  492. # We had no edits to make and the contents are the same
  493. if yamlfile.yaml_dict == content and \
  494. params['value'] is None:
  495. return {'changed': False, 'result': yamlfile.yaml_dict, 'state': state}
  496. yamlfile.yaml_dict = content
  497. # If we were passed a key, value then
  498. # we enapsulate it in a list and process it
  499. # Key, Value passed to the module : Converted to Edits list #
  500. edits = []
  501. _edit = {}
  502. if params['value'] is not None:
  503. _edit['value'] = params['value']
  504. _edit['value_type'] = params['value_type']
  505. _edit['key'] = params['key']
  506. if params['update']:
  507. _edit['action'] = 'update'
  508. _edit['curr_value'] = params['curr_value']
  509. _edit['curr_value_format'] = params['curr_value_format']
  510. _edit['index'] = params['index']
  511. elif params['append']:
  512. _edit['action'] = 'append'
  513. edits.append(_edit)
  514. elif params['edits'] is not None:
  515. edits = params['edits']
  516. if edits:
  517. results = Yedit.process_edits(edits, yamlfile)
  518. # if there were changes and a src provided to us we need to write
  519. if results['changed'] and params['src']:
  520. yamlfile.write()
  521. return {'changed': results['changed'], 'result': results['results'], 'state': state}
  522. # no edits to make
  523. if params['src']:
  524. # pylint: disable=redefined-variable-type
  525. rval = yamlfile.write()
  526. return {'changed': rval[0],
  527. 'result': rval[1],
  528. 'state': state}
  529. return {'failed': True, 'msg': 'Unkown state passed'}