comparables.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. # -*- coding: utf-8 -*-
  2. '''
  3. tests.support.comparables
  4. ~~~~~~~~~~~~~~~~~~~~~~~~~
  5. Comparable data structures for pytest assertions
  6. '''
  7. # Import Python libs
  8. from __future__ import absolute_import, unicode_literals, print_function
  9. import re
  10. import pprint
  11. import logging
  12. # Import Salt libs
  13. import salt.ext.six as six
  14. from salt.utils.odict import OrderedDict
  15. log = logging.getLogger(__name__)
  16. class ComparableSubDict(dict):
  17. __comparable_keys__ = ()
  18. def __init__(self, *args, **kwargs):
  19. super(ComparableSubDict, self).__init__(*args, **kwargs)
  20. self._original = self.copy()
  21. self._comparable_subset = OrderedDict()
  22. def get_comparable_dict(self):
  23. if not self._comparable_subset:
  24. for key in self.__comparable_keys__:
  25. if key not in self:
  26. continue
  27. self._comparable_subset[key] = self[key]
  28. return self._comparable_subset
  29. def construct_comparable_instance(self, data):
  30. return self.__class__(data)
  31. def __repr__(self):
  32. return '<{} {}>'.format(self.__class__.__name__, dict.__repr__(self))
  33. def __eq__(self, other):
  34. return self.compare_with(other, explain=False) is True
  35. def __ne__(self, other):
  36. return self.compare_with(other, explain=False) is False
  37. def explain_comparisson_with(self, other):
  38. return self.compare_with(other, explain=True)
  39. def compare_with(self, other, explain=False):
  40. if not isinstance(other, self.__class__):
  41. other = self.construct_comparable_instance(other)
  42. if explain:
  43. explanation = ['Comparing instances of {}:'.format(self.__class__.__name__)]
  44. comparable_self = self.get_comparable_dict()
  45. comparable_other = other.get_comparable_dict()
  46. if comparable_self == comparable_other:
  47. # They match directly, return fast
  48. return True
  49. if comparable_self and not comparable_other or not comparable_self and comparable_other:
  50. # Make sure comparissons are not run if one of the dictionaries is empty but not both
  51. if explain:
  52. explanation.append(
  53. ' - Cannot compare instances of {} against an empty comparable counterpart'.format(
  54. self.__class__.__name__
  55. )
  56. )
  57. return explanation
  58. return False
  59. if not set(comparable_self).intersection(set(comparable_other)):
  60. # There's not a single key to compare, we can't blindly match
  61. if explain:
  62. explanation.append(
  63. ' - Nothing to compare because the intersection of the comparable keys is empty.'
  64. )
  65. return explanation
  66. return False
  67. for key in comparable_self:
  68. if key not in comparable_other:
  69. continue
  70. comparable_self_value = comparable_self[key]
  71. comparable_other_value = comparable_other[key]
  72. if explain:
  73. if isinstance(comparable_self_value, ComparableSubDict):
  74. explanation.extend(comparable_self_value.explain_comparisson_with(comparable_other_value))
  75. continue
  76. # pylint: disable=repr-flag-used-in-string
  77. compare_func = getattr(self, 'compare_{}'.format(key), None)
  78. if compare_func is not None:
  79. comparisson_matched = compare_func(comparable_self_value, comparable_other_value) is True
  80. else:
  81. comparisson_matched = comparable_self_value == comparable_other_value
  82. if not comparisson_matched:
  83. if explain:
  84. explanation.extend(pprint.pformat(comparable_other_value).splitlines())
  85. explanation.append(' - The values for the \'{}\' key do not match:'.format(key))
  86. explanation.append(' {!r} != {!r}'.format(comparable_self_value, comparable_other_value))
  87. else:
  88. return False
  89. # pylint: enable=repr-flag-used-in-string
  90. if explain:
  91. return explanation
  92. return True
  93. class ComparableChangesMixin(object):
  94. def compare_changes(self, this_value, other_value):
  95. if this_value == other_value:
  96. # They match directly, return fast
  97. return True
  98. if not set(this_value).intersection(set(other_value)):
  99. # There's not a single key to compare, we can't run a "sub" comparison
  100. return False
  101. for key in this_value:
  102. if key not in other_value:
  103. continue
  104. t_value = this_value[key]
  105. o_value = other_value[key]
  106. if isinstance(t_value, bool):
  107. if t_value is not o_value:
  108. return False
  109. elif isinstance(t_value, str):
  110. if t_value == o_value:
  111. # Values match directly
  112. continue
  113. if re.match(o_value, t_value, re.DOTALL) is None:
  114. # Didn't match using regex
  115. return False
  116. else:
  117. if t_value != o_value:
  118. return False
  119. return True
  120. class ComparableCommentMixin(object):
  121. def compare_comment(self, this_value, other_value):
  122. '''
  123. We support regex matching on comparissons
  124. '''
  125. if not isinstance(this_value, six.string_types):
  126. this_value = '\n'.join(this_value)
  127. if not isinstance(other_value, six.string_types):
  128. other_value = '\n'.join(other_value)
  129. if this_value == other_value:
  130. return True
  131. return re.match(other_value, this_value, re.DOTALL) is not None
  132. class ComparableResultMixin(object):
  133. def compare_result(self, this_value, other_value):
  134. return this_value is other_value
  135. class ComparableStateEntry(ComparableSubDict,
  136. ComparableChangesMixin,
  137. ComparableCommentMixin,
  138. ComparableResultMixin):
  139. '''
  140. We create a state entry which subclasses from a dictionary
  141. because we want to allow pytest to run specific assertions
  142. against the contents.
  143. '''
  144. __comparable_keys__ = ('__id__', '__sls__', 'changes', 'comment', 'name', 'result', 'status', 'duration')
  145. def compare_name(self, this_value, other_value):
  146. '''
  147. We support regex matching on comparissons
  148. '''
  149. if this_value == other_value:
  150. return True
  151. return re.match(other_value, this_value) is not None
  152. def compare___id__(self, this_value, other_value):
  153. return self.compare_name(this_value, other_value)
  154. def compare___sls__(self, this_value, other_value):
  155. return self.compare_name(this_value, other_value)
  156. def compare__saltfunc__(self, this_value, other_value):
  157. return self.compare_name(this_value, other_value)
  158. def compare__state_entry_name__(self, this_value, other_value):
  159. return self.compare_name(this_value, other_value)
  160. def compare_status(self, this_value, other_value):
  161. return this_value == other_value
  162. def compare_duration(self, this_value, other_value):
  163. # Duration's are floats
  164. if this_value == other_value:
  165. return True
  166. # Cast to string for regex matching
  167. return self.compare_name(str(this_value), str(other_value))
  168. class StateReturn(ComparableSubDict):
  169. '''
  170. We create a state return which subclasses from a dictionary
  171. because we want to allow pytest to run specific assertions
  172. against the contents.
  173. '''
  174. __comparable_keys__ = ('state_entries',)
  175. def __init__(self, *args, **kwargs):
  176. super(StateReturn, self).__init__(*args, **kwargs)
  177. self.setdefault('state_entries', [])
  178. for idx, passed_in_state_entry in enumerate(self['state_entries']):
  179. self['state_entries'][idx] = ComparableStateEntry(passed_in_state_entry)
  180. state_entries = {}
  181. for key in list(self):
  182. if not isinstance(self[key], dict):
  183. continue
  184. if '_|-' in key or key.startswith('RE:') or key in ('*', '.*') or '__sls__' in self[key]:
  185. new_key = None
  186. if key.startswith('RE:'):
  187. new_key = key[3:]
  188. state_entries[new_key or key] = self.pop(key)
  189. for key, value in sorted(state_entries.items(), key=lambda kv: kv[1]['__run_num__']):
  190. value['__state_entry_name__'] = key
  191. self['state_entries'].append(ComparableStateEntry(value))
  192. # If by now state entries are empty, remove it because it's comparisson function,
  193. # compare_state_entries is quite permissive and would allow things like:
  194. # {} == a != {}
  195. if not self['state_entries']:
  196. self.pop('state_entries')
  197. def construct_comparable_instance(self, data):
  198. for key, value in data.items():
  199. if ('_|-' in key or key in ('*', '.*') or '__sls__' in value) and '__run_num__' not in value:
  200. value['__run_num__'] = 0
  201. return super(StateReturn, self).construct_comparable_instance(data)
  202. def compare_state_entries(self, this_value, other_value):
  203. if this_value and not other_value or not this_value and other_value:
  204. return True
  205. return this_value == other_value
  206. @property
  207. def result(self):
  208. return all([state['result'] for state in self.get('state_entries') or ()])
  209. def items(self):
  210. _items = {}
  211. for state_entry in self.get('state_entries', ()):
  212. state_entry_copy = state_entry.copy()
  213. _items[state_entry_copy.pop('__state_entry_name__')] = state_entry_copy
  214. return _items.items()
  215. def keys(self):
  216. _keys = []
  217. for state_entry in self.get('state_entries', ()):
  218. _keys.append(state_entry['__state_entry_name__'])
  219. return _keys
  220. def values(self):
  221. _values = []
  222. for _, value in self.items():
  223. _values.append(value)
  224. return _values
  225. class StateReturnError(list):
  226. def construct_comparable_instance(self, data):
  227. if not isinstance(data, list):
  228. data = [data]
  229. return self.__class__(data)
  230. def __repr__(self):
  231. return '<{} {}>'.format(self.__class__.__name__, list.__repr__(self))
  232. def __eq__(self, other):
  233. return self.compare_with(other, explain=False) is True
  234. def __ne__(self, other):
  235. return self.compare_with(other, explain=False) is False
  236. def __contains__(self, other):
  237. return self.compare_with(other, explain=False) is True
  238. def explain_comparisson_with(self, other):
  239. return self.compare_with(other, explain=True)
  240. def compare_with(self, other, explain=False):
  241. if not isinstance(other, self.__class__):
  242. other = self.construct_comparable_instance(other)
  243. if explain:
  244. explanation = ['Comparing instances of {}:'.format(self.__class__.__name__)]
  245. #if self == other:
  246. # # They match directly, return fast
  247. # return True
  248. if self and not other or not self and other:
  249. # Make sure comparissons are not run if one of the lists is empty but not both
  250. if explain:
  251. explanation.append(
  252. ' - Cannot compare instances of {} against an empty comparable counterpart'.format(
  253. self.__class__.__name__
  254. )
  255. )
  256. return explanation
  257. return False
  258. # pylint: disable=repr-flag-used-in-string
  259. comparisson_matched = self.compare_errors(other)
  260. if not comparisson_matched:
  261. if explain:
  262. explanation.append(' - The state errors do not match:')
  263. explanation.append(' {!r} != {!r}'.format(self, other))
  264. else:
  265. return False
  266. # pylint: enable=repr-flag-used-in-string
  267. if explain:
  268. return explanation
  269. return True
  270. def compare_errors(self, other_value):
  271. '''
  272. We support regex matching on comparissons
  273. '''
  274. this_value = '\n'.join(self)
  275. other_value = '\n'.join(other_value)
  276. if this_value == other_value:
  277. return True
  278. return re.match(other_value, this_value, re.DOTALL) is not None