collection of python libs developed for testing purposes

hoover.py 47KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393
  1. # coding=utf-8
  2. import collections
  3. import csv
  4. import difflib
  5. import hashlib
  6. import inspect
  7. import itertools
  8. import json
  9. import operator
  10. import time
  11. from copy import deepcopy
  12. # ########################################################################### #
  13. # ## The Motor ## #
  14. # ########################################################################### #
  15. def regression_test(argsrc, tests, driver_settings, cleanup_hack=None,
  16. apply_hacks=None, on_next=None):
  17. """Perform regression test with argsets from `argsrc`.
  18. For each argset pulled from source, performs one comparison
  19. per driver pair in `tests`, which is list of tuples with
  20. comparison function and pair of test driver classes: `(operator,
  21. oracle_class, result_class)`. (The classes are assumed to
  22. be sub-classes of `hoover.BaseTestDriver`.)
  23. `driver_settings` is a dictionary supposed to hold environmental
  24. values for all the drivers, the keys having form "DriverName.
  25. settingName". Each driver is then instantiated with this
  26. dict, and gets a copy of the dict with settings only intended
  27. for itself (and the "DriverName" part stripped).
  28. If comparison fails, report is generated using `hoover.jsDiff()`,
  29. and along with affected arguments stored in `hoover.Tracker`
  30. instance, which is finally used as a return value. This instance
  31. then contains method for basic stats as well as method to format
  32. the final report and a helper method to export argument sets
  33. as a CSV files.
  34. Supports hacks, which are a data transformations performed by
  35. `hoover.TinyCase` class and are intended to avoid known bugs
  36. and anomalies (`apply_hacks`) or clean up data structures of
  37. irrelevant data (`cleanup_hack`, performed only if the comparison
  38. function provided along with driver pair is not "equals").
  39. A function can be provided as `on_next` argument, that will be
  40. called after pulling each argument set, with last argument set
  41. (or `None`) as first argument and current one as second argument.
  42. """
  43. # TODO: do not parse driver_settings thousands of times (use a view class?)
  44. on_next = on_next if on_next else lambda a, b: None
  45. apply_hacks = apply_hacks if apply_hacks else []
  46. tracker = Tracker()
  47. last_argset = None
  48. all_classes = set(reduce(lambda a, b: a+b,
  49. [triple[1:] for triple in tests]))
  50. counter = StatCounter()
  51. for argset in argsrc:
  52. on_start = time.time()
  53. on_next(argset, last_argset)
  54. counter.add('on_next', time.time() - on_start)
  55. # # load the data first, only once for each driver
  56. #
  57. data = {}
  58. for aclass in all_classes:
  59. try:
  60. aclass.check_values(argset)
  61. except NotImplementedError: # let them bail out
  62. counter.count_for(aclass, 'bailouts')
  63. else:
  64. data[aclass], duration, overhead = get_data_and_stats(
  65. aclass, argset, driver_settings)
  66. counter.count_for(aclass, 'calls')
  67. counter.add_for(aclass, 'duration', duration)
  68. counter.add_for(aclass, 'overhead', overhead)
  69. for match_op, oclass, rclass in tests:
  70. # skip test if one of classes bailed out on the argset
  71. if oclass not in data or rclass not in data:
  72. continue
  73. diff = None
  74. case = TinyCase({
  75. 'argset': argset,
  76. 'oracle': deepcopy(data[oclass]),
  77. 'result': deepcopy(data[rclass]),
  78. 'oname': oclass.__name__,
  79. 'rname': rclass.__name__
  80. })
  81. hacks_done = sum([case.hack(h) for h in apply_hacks])
  82. counter.add_for(oclass, 'ohacks', hacks_done)
  83. counter.add_for(rclass, 'rhacks', hacks_done)
  84. counter.add('hacks', hacks_done)
  85. counter.add('hacked_cases', (1 if hacks_done else 0))
  86. if not match_op(case['oracle'], case['result']):
  87. # try to clean up so that normally ignored items
  88. # do not clutter up the report
  89. if not match_op == operator.eq:
  90. case.hack(cleanup_hack)
  91. # but panic if that "removed" the error condition
  92. if match_op(case['oracle'], case['result']):
  93. raise RuntimeError("cleanup ate error")
  94. diff = jsDiff(dira=case['oracle'],
  95. dirb=case['result'],
  96. namea=case['oname'],
  97. nameb=case['rname'])
  98. tracker.update(diff, argset)
  99. counter.count('cases')
  100. tracker.argsets_done += 1
  101. last_argset = argset
  102. counter.count('argsets')
  103. tracker.driver_stats = counter.all_stats()
  104. return tracker
  105. def get_data_and_stats(driverClass, argset, driver_settings):
  106. """Run test with given driver"""
  107. start = time.time()
  108. d = driverClass()
  109. d.setup(driver_settings, only_own=True)
  110. d.run(argset)
  111. return (d.data, d.duration, time.time() - d.duration - start)
  112. def get_data(driverClass, argset, driver_settings):
  113. """Run test with given driver"""
  114. d = driverClass()
  115. d.setup(driver_settings, only_own=True)
  116. d.run(argset)
  117. return d.data
  118. # ########################################################################### #
  119. # ## The Pattern ## #
  120. # ########################################################################### #
  121. class _BaseRuleOp(object):
  122. def __init__(self, items, item_ok):
  123. self._items = items
  124. self._item_ok = item_ok
  125. def _eval(self, item):
  126. try: # it's a pattern! (recurse)
  127. return RuleOp.Match(item, self._item_ok)
  128. except ValueError: # no, it's something else...
  129. return self._item_ok(item)
  130. def __nonzero__(self):
  131. try:
  132. return self._match()
  133. except TypeError:
  134. raise ValueError("items must be an iterable: %r" % self._items)
  135. class RuleOp(object):
  136. class ALL(_BaseRuleOp):
  137. def _match(self):
  138. return all(self._eval(item) for item in self._items)
  139. class ANY(_BaseRuleOp):
  140. def _match(self):
  141. return any(self._eval(item) for item in self._items)
  142. @staticmethod
  143. def Match(pattern, item_ok):
  144. """Evaluate set of logically structured patterns using passed function.
  145. pattern has form of `(op, [item1, item2, ...])` where op can be any of
  146. pre-defined logical operators (`ALL`/`ANY`, I doubt you will ever need
  147. more) and item_ok is a function that will be used to evaluate each one
  148. in the list. In case an itemN is actually pattern as well, it will be
  149. recursed into, passing the item_ok on and on.
  150. Note that there is no data to evaluate "against", you can use closure
  151. if you need to do that.
  152. """
  153. try:
  154. op, items = pattern
  155. except TypeError:
  156. raise ValueError("pattern is not a tuple: %r" % pattern)
  157. try:
  158. assert issubclass(op, _BaseRuleOp)
  159. except TypeError:
  160. raise ValueError("invalid operator: %r" % op)
  161. except AssertionError:
  162. raise ValueError("invalid operator class: %s" % op.__name__)
  163. return bool(op(items, item_ok))
  164. # ########################################################################### #
  165. # ## The Path ## #
  166. # ########################################################################### #
  167. class DictPath(object):
  168. """Mixin that adds "path-like" behavior to the top dict of dicts.
  169. Use this class as a mixin for a deep dic-like structure and you can access
  170. the elements using a path. For example:
  171. MyData(dict, DictPath):
  172. pass
  173. d = MyData({
  174. 'name': 'Joe',
  175. 'age': 34,
  176. 'ssn': {
  177. 'number': '012 345 678',
  178. 'expires': '10-01-16',
  179. },
  180. })
  181. print ("%s's ssn number %s will expire on %s"
  182. % (d.getpath('/name'),
  183. d.getpath('/ssn/number'),
  184. d.getpath('/ssn/expiry')))
  185. # joe's ssn number 012 345 678 will expire 10-01-16
  186. """
  187. DIV = "/"
  188. class Path(object):
  189. def __init__(self, path, div):
  190. self.DIV = div
  191. self._path = path
  192. def _validate(self):
  193. try:
  194. assert self._path.startswith(self.DIV)
  195. except (AttributeError, AssertionError):
  196. raise ValueError("invalid path: %r" % self._path)
  197. def stripped(self):
  198. return self._path.lstrip(self.DIV)
  199. @classmethod
  200. def __s2path(cls, path):
  201. return cls.Path(path, cls.DIV)
  202. @classmethod
  203. def __err_path_not_found(cls, path):
  204. raise KeyError("path not found: %s" % path)
  205. @classmethod
  206. def __getitem(cls, dct, key):
  207. if cls.DIV in key:
  208. frag, rest = key.split(cls.DIV, 1)
  209. subdct = dct[frag]
  210. result = cls.__getitem(subdct, rest)
  211. else:
  212. result = dct[key]
  213. return result
  214. @classmethod
  215. def __setitem(cls, dct, key, value):
  216. if cls.DIV not in key:
  217. dct[key] = value
  218. else:
  219. frag, rest = key.split(cls.DIV, 1)
  220. subdct = dct[frag]
  221. cls.__setitem(subdct, rest, value)
  222. @classmethod
  223. def __delitem(cls, dct, key):
  224. if cls.DIV not in key:
  225. del dct[key]
  226. else:
  227. frag, rest = key.split(cls.DIV, 1)
  228. subdct = dct[frag]
  229. return cls.__delitem(subdct, rest)
  230. # # public methods
  231. #
  232. def getpath(self, path):
  233. try:
  234. return self.__getitem(self, self.__s2path(path).stripped())
  235. except (TypeError, KeyError):
  236. self.__err_path_not_found(path)
  237. def setpath(self, path, value):
  238. try:
  239. self.__setitem(self, self.__s2path(path).stripped(), value)
  240. except (TypeError, KeyError):
  241. self.__err_path_not_found(path)
  242. def delpath(self, path):
  243. try:
  244. self.__delitem(self, self.__s2path(path).stripped())
  245. except (TypeError, KeyError):
  246. self.__err_path_not_found(path)
  247. def ispath(self, path):
  248. try:
  249. self.getpath(path)
  250. return True
  251. except KeyError:
  252. return False
  253. # ########################################################################### #
  254. # ## The Case ## #
  255. # ########################################################################### #
  256. class TinyCase(dict, DictPath):
  257. """Abstraction of the smallest unit of testing.
  258. This class is intended to hold relevant data after the actual test
  259. and apply transformations (hacks) as defined by rules.
  260. The data form (self) is:
  261. {
  262. 'argset': {}, # argset as fed into `BaseTestDriver.run`
  263. 'oracle': {}, # data as returned from oracle driver's `run()`
  264. 'result': {}, # data as returned from result driver's `run()`
  265. 'oname': "", # name of oracle driver's class
  266. 'rname': "" # name of result driver's class
  267. }
  268. The transformation is done using the `TinyCase.hack()` method to which
  269. a list of rules is passed. Each rule is applied, and rules are expected
  270. to be in a following form:
  271. {
  272. 'drivers': [{}], # list of structures to match against self
  273. 'argsets': [{}], # -ditto-
  274. 'action_name': <Arg> # an action name with argument
  275. }
  276. For each of patterns ('drivers', argsets') present, match against self
  277. is done using function `hoover.dataMatch`, which is basically a recursive
  278. test if the pattern is a subset of the case. If none of results is
  279. negative (i.e. both patterns missing results in match), any known actions
  280. included in the rule are called. Along with action name a list or a dict
  281. providing necessary parameters is expected: this is simply passed as only
  282. parameter to corresponding method.
  283. Actions use specific way how to address elements in the structures
  284. saved in the oracle and result keys provided by `DictPath`, which makes
  285. it easy to define rules for arbitrarily complex dictionary structures.
  286. The format resembles to Unix path, where "directories" are dict
  287. keys and "root" is the `self` of the `TinyCase` instance:
  288. /oracle/temperature
  289. /result/stats/word_count
  290. Refer to each action's docstring for descriprion of their function
  291. as well as expected format of argument. The name of action as used
  292. in the reule is the name of method without leading 'a_'.
  293. Warning: All actions will silently ignore any paths that are invalid
  294. or leading to non-existent data!
  295. (This does not apply to a path leading to `None`.)
  296. """
  297. def a_exchange(self, action):
  298. """Exchange value A for value B.
  299. Expects a dict, where key is a tuple of two values `(a, b)` and
  300. value is a list of paths. For each key, it goes through the
  301. paths and if the value equals `a` it is set to `b`.
  302. """
  303. for (oldv, newv), paths in action.iteritems():
  304. for path in paths:
  305. try:
  306. curv = self.getpath(path)
  307. except KeyError:
  308. continue
  309. else:
  310. if curv == oldv:
  311. self.setpath(path, newv)
  312. def a_format_str(self, action):
  313. """Convert value to a string using format string.
  314. Expects a dict, where key is a format string, and value is a list
  315. of paths. For each record, the paths are traversed, and value is
  316. converted to string using the format string and the `%` operator.
  317. This is especially useful for floats which you may want to trim
  318. before comparison, since direct comparison of floats is unreliable
  319. on some architectures.
  320. """
  321. for fmt, paths in action.iteritems():
  322. for path in paths:
  323. if self.ispath(path):
  324. new = fmt % self.getpath(path)
  325. self.setpath(path, new)
  326. def a_even_up(self, action):
  327. """Even up structure of both dictionaries.
  328. Expects a list of two-element tuples `('/dict/a', '/dict/b')`
  329. containing pairs of path do simple dictionaries.
  330. Then the two dicts are altered to have same structure: if a key
  331. in dict "a" is missing in dict "b", it is set to `None` in "b" and
  332. vice-versa,
  333. """
  334. for patha, pathb in action:
  335. try:
  336. a = self.getpath(patha)
  337. b = self.getpath(pathb)
  338. except KeyError:
  339. continue
  340. else:
  341. for key in set(a.keys()) | set(b.keys()):
  342. if key in a and key in b:
  343. pass # nothing to do here
  344. elif key in a and a[key] is None:
  345. b[key] = None
  346. elif key in b and b[key] is None:
  347. a[key] = None
  348. else:
  349. pass # bailout: odd key but value is *not* None
  350. def a_remove(self, action):
  351. """Remove elements from structure.
  352. Expects a simple list of paths that are simply deleted fro, the
  353. structure.
  354. """
  355. for path in action:
  356. if self.ispath(path):
  357. self.delpath(path)
  358. def a_round(self, action):
  359. """Round a (presumably) float using tha `float()` built-in.
  360. Expects dict with precision (ndigits, after the dot) as a key and
  361. list of paths as value.
  362. """
  363. for ndigits, paths in action.iteritems():
  364. for path in paths:
  365. try:
  366. f = self.getpath(path)
  367. except KeyError:
  368. pass
  369. else:
  370. self.setpath(path, round(f, ndigits))
  371. known_actions = {'remove': a_remove,
  372. 'even_up': a_even_up,
  373. 'format_str': a_format_str,
  374. 'exchange': a_exchange,
  375. 'round': a_round}
  376. def hack(self, ruleset):
  377. """Apply action from each rule, if patterns match."""
  378. def driver_matches(rule):
  379. if 'drivers' not in rule:
  380. return True
  381. else:
  382. return any(dataMatch(p, self)
  383. for p in rule['drivers'])
  384. def argset_matches(rule):
  385. if 'argsets' not in rule:
  386. return True
  387. else:
  388. return any(dataMatch(p, self)
  389. for p in rule['argsets'])
  390. matched = False
  391. cls = self.__class__
  392. for rule in ruleset:
  393. if driver_matches(rule) and argset_matches(rule):
  394. matched = True
  395. for action_name in cls.known_actions:
  396. if action_name in rule:
  397. cls.known_actions[action_name](self, rule[action_name])
  398. return matched
  399. # ########################################################################### #
  400. # ## Drivers ## #
  401. # ########################################################################### #
  402. class DriverError(Exception):
  403. """Error encountered when obtaining driver data"""
  404. def __init__(self, message, driver):
  405. self.message = message
  406. self.driver = driver
  407. def __str__(self):
  408. result = ("\n\n"
  409. " type: %s\n"
  410. " message: %s\n"
  411. " driver: %s\n"
  412. " args: %s\n"
  413. " settings: %s\n"
  414. % (self.message.__class__.__name__,
  415. self.message,
  416. self.driver.__class__.__name__,
  417. self.driver._args,
  418. self.driver._settings))
  419. return result
  420. class DriverDataError(Exception):
  421. """Error encountered when decoding or normalizing driver data"""
  422. def __init__(self, exception, driver):
  423. self.exception = exception
  424. self.driver = driver
  425. def __str__(self):
  426. result = ("%s: %s\n"
  427. " class: %s\n"
  428. " args: %s\n"
  429. " data: %s\n"
  430. % (self.exception.__class__.__name__, self.exception,
  431. self.driver.__class__.__name__,
  432. json.dumps(self.driver._args, sort_keys=True, indent=4),
  433. json.dumps(self.driver.data, sort_keys=True, indent=4)))
  434. return result
  435. class BaseTestDriver(object):
  436. """Base class for test drivers used by `hoover.regression_test` and others.
  437. This class is used to create a test driver, which is an abstraction
  438. and encapsulation of the system being tested. Or, the driver in fact
  439. can be just a "mock" driver that provides data for comparison with
  440. a "real" driver.
  441. The minimum you need to create a working driver is to implement a working
  442. `self._get_data` method that sets `self.data`. Any exception from this
  443. method will be re-raised as DriverError with additional information.
  444. Also, you can set self.duration (in fractional seconds, as returned by
  445. standard time module) in the _get_data method, but if you don't, it is
  446. measured for you as time the method call took. This is useful if you
  447. need to fetch the data from some other driver or a gateway, and you
  448. have better mechanism to determine how long the action would take "in
  449. real life".
  450. For example, if we are testing a Java library using a Py4J gateway,
  451. we need to do some more conversions outside our testing code just to
  452. be able to use the data in our Python test. We don't want to include
  453. this in the "duration", since we are measuring the Java library, not the
  454. Py4J GW (or our ability to perform the conversions optimally). So we
  455. do our measurement within the Java machine and pass the result to the
  456. Python driver.
  457. Optionally, you can:
  458. * Make an __init__ and after calling base __init__, set
  459. * `self._mandatory_args`, a list of keys that need to be present
  460. in `args` argument to `run()`
  461. * and `self._mandatory_settings`, a list of keys that need to be
  462. present in the `settings` argument to `__init__`
  463. * implement methods
  464. * `_decode_data` and `_normalize_data`, which are intended to decode
  465. the data from any raw format it is received, and to prepare it
  466. for comparison in test,
  467. * and `_check_data`, to allow for early detection of failure,
  468. from which any exception is re-raised as a DriverDataError with
  469. some additional info
  470. * set "bailouts", a list of functions which, when passed "args"
  471. argument, return true to indicate that driver is not able to
  472. process these values (see below for explanation). If any of
  473. these functions returns true, NotImplementedError is raised.
  474. The expected workflow when using the driver is:
  475. # 1. sub-class hoover.BaseTestDriver
  476. # 2. prepare settings and args
  477. MyDriver.check_values(args) # optional, to force bailouts ASAP
  478. d = MyDriver()
  479. d.setup(settings)
  480. d.run(args)
  481. assert d.data, "no data" # evaluate the result...
  482. assert d.duration < 1 # duration of _get_data in seconds
  483. Note on bailouts: Typical strategy for which the driver is intended is
  484. that each possible combination of `args` is exhausted, and results from
  485. multiple drivers are compared to evaluate if driver, i.e. system in
  486. question is O.K.
  487. The bailouts mechanism is useful in cases, where for a certain system,
  488. a valid combination of arguments would bring the same result as another,
  489. so there is basically no value in testing both of them.
  490. Example might be a system that does not support a binary flag and
  491. behaves as if it was "on": you can simply make the test driver
  492. accept the option but "bail out" any time it is "off", therefore
  493. skipping the time-and-resource-consuming test.
  494. """
  495. bailouts = []
  496. ##
  497. # internal methods
  498. #
  499. def __init__(self):
  500. self.data = {}
  501. self.duration = None
  502. self._args = {}
  503. self._mandatory_args = []
  504. self._mandatory_settings = []
  505. self._settings = {}
  506. self._setup_ok = False
  507. def __check_mandatory(self):
  508. """validate before run()"""
  509. for key in self._mandatory_args:
  510. assert key in self._args, "missing arg: '%s'" % key
  511. for key in self._mandatory_settings:
  512. assert key in self._settings, "missing setting: '%s'" % key
  513. def __cleanup_data(self):
  514. """remove hidden data; e.g. what was only there for _check_data"""
  515. for key in self.data.keys():
  516. if key.startswith("_"):
  517. del self.data[key]
  518. ##
  519. # virtual methods
  520. #
  521. def _check_data(self):
  522. """Early check for failure"""
  523. pass
  524. def _decode_data(self):
  525. """Decode from raw data as brought by _get_data"""
  526. pass
  527. def _normalize_data(self):
  528. """Preare data for comparison (e.g. sort, split, trim...)"""
  529. pass
  530. ##
  531. # public methods
  532. #
  533. @classmethod
  534. def check_values(cls, args=None):
  535. """check args in advance before running or setting up anything"""
  536. for fn in cls.bailouts:
  537. if fn(args):
  538. raise NotImplementedError(inspect.getsource(fn))
  539. def setup(self, settings, only_own=False):
  540. """Load settings. only_own means that only settings that belong to us
  541. are loaded ("DriverClass.settingName", the first discriminating part
  542. is removed)"""
  543. if only_own:
  544. for ckey in settings.keys():
  545. driver_class_name, setting_name = ckey.split(".", 2)
  546. if self.__class__.__name__ == driver_class_name:
  547. self._settings[setting_name] = settings[ckey]
  548. else:
  549. self._settings = settings
  550. self._setup_ok = True
  551. def run(self, args):
  552. """validate, run and store data"""
  553. self._args = args
  554. assert self._setup_ok, "run() before setup()?"
  555. self.__class__.check_values(self._args)
  556. self.__check_mandatory()
  557. start = time.time()
  558. try:
  559. self._get_data() # run the test, i.e. obtain raw data
  560. except StandardError as e:
  561. raise DriverError(e, self)
  562. self.duration = (time.time() - start if self.duration is None
  563. else self.duration)
  564. try:
  565. self._decode_data() # decode raw data
  566. self._normalize_data() # normalize decoded data
  567. self._check_data() # perform arbitrarty checking
  568. except StandardError, e:
  569. raise DriverDataError(e, self)
  570. self.__cleanup_data() # cleanup (remove data['_*'])
  571. class MockDriverTrue(BaseTestDriver):
  572. """A simple mock driver, always returning True"""
  573. def _get_data(self, args):
  574. self.data = True
  575. # ########################################################################### #
  576. # ## Helpers ## #
  577. # ########################################################################### #
  578. class StatCounter(object):
  579. """A simple counter with formulas support."""
  580. def __init__(self):
  581. self.generic_stats = {}
  582. self.driver_stats = {}
  583. self.formulas = {}
  584. self._born = time.time()
  585. def _register(self, dname):
  586. self.driver_stats[dname] = {
  587. 'calls': 0,
  588. 'rhacks': 0,
  589. 'ohacks': 0,
  590. 'duration': 0,
  591. 'overhead': 0
  592. }
  593. ##
  594. # Formulas
  595. #
  596. # cumulative duration/overhead; just round to ms
  597. self.add_formula(dname + '_overhead',
  598. lambda g, d: int(1000 * d[dname]['overhead']))
  599. self.add_formula(dname + '_duration',
  600. lambda g, d: int(1000 * d[dname]['duration']))
  601. # average (per driver call) overhead/duration
  602. self.add_formula(
  603. dname + '_overhead_per_call',
  604. lambda g, d: int(1000 * d[dname]['overhead'] / d[dname]['calls'])
  605. )
  606. self.add_formula(
  607. dname + '_duration_per_call',
  608. lambda g, d: int(1000 * d[dname]['duration'] / d[dname]['calls'])
  609. )
  610. def gtotal_drivertime(g, d):
  611. driver_time = (sum(s['overhead'] for s in d.values())
  612. + sum(s['duration'] for s in d.values()))
  613. return int(1000 * driver_time)
  614. def gtotal_loop_overhead(g, d):
  615. driver_time = gtotal_drivertime(g, d)
  616. onnext_time = int(1000 * g['on_next'])
  617. age = int(1000 * (time.time() - self._born))
  618. return age - driver_time - onnext_time
  619. # grand totals in times: driver time, loop overhead
  620. self.add_formula('gtotal_drivertime', gtotal_drivertime)
  621. self.add_formula('gtotal_loop_overhead', gtotal_loop_overhead)
  622. self.add_formula('gtotal_loop_onnext',
  623. lambda g, d: int(1000 * g['on_next']))
  624. # average (per driver call) overhead/duration
  625. self.add_formula(
  626. 'cases_hacked',
  627. lambda g, d: round(100 * float(g['hacked_cases']) / g['cases'], 2)
  628. )
  629. def _computed_stats(self):
  630. computed = dict.fromkeys(self.formulas.keys())
  631. for fname, fml in self.formulas.iteritems():
  632. try:
  633. v = fml(self.generic_stats, self.driver_stats)
  634. except ZeroDivisionError:
  635. v = None
  636. computed[fname] = v
  637. return computed
  638. def add_formula(self, vname, formula):
  639. """Add a function to work with generic_stats, driver_stats."""
  640. self.formulas[vname] = formula
  641. def add(self, vname, value):
  642. """Add a value to generic stat counter."""
  643. if vname in self.generic_stats:
  644. self.generic_stats[vname] += value
  645. else:
  646. self.generic_stats[vname] = value
  647. def add_for(self, dclass, vname, value):
  648. """Add a value to driver stat counter."""
  649. dname = dclass.__name__
  650. if dname not in self.driver_stats:
  651. self._register(dname)
  652. if vname in self.driver_stats[dname]:
  653. self.driver_stats[dname][vname] += value
  654. else:
  655. self.driver_stats[dname][vname] = value
  656. def count(self, vname):
  657. """Alias to add(vname, 1)"""
  658. self.add(vname, 1)
  659. def count_for(self, dclass, vname):
  660. """Alias to add_for(vname, 1)"""
  661. self.add_for(dclass, vname, 1)
  662. def all_stats(self):
  663. """Compute stats from formulas and add them to colledted data."""
  664. stats = self.generic_stats
  665. for dname, dstats in self.driver_stats.iteritems():
  666. for key, value in dstats.iteritems():
  667. stats[dname + "_" + key] = value
  668. stats.update(self._computed_stats())
  669. return stats
  670. class Tracker(dict):
  671. """Error tracker to allow for usable reports from huge regression tests.
  672. Best used as a result bearer from `regression_test`, this class keeps
  673. a simple in-memory "database" of errors seen during the regression
  674. test, and implements few methods to access the data.
  675. The basic usage is:
  676. 1. Instantiate (no parameters)
  677. 2. Each time you have a result of a test, you pass it to `update()`
  678. method along with the argument set (as a single object, typically
  679. a dict) that caused the error.
  680. If boolean value of the result is False, the object is thrown away
  681. and nothing happen. Otherwise, its string value is used as a key
  682. under which the argument set is saved.
  683. As you can see, the string is supposed to be ''as deterministic
  684. as possible'', i.e. it should provide as little information
  685. about the error as is necessary. Do not include any timestamps
  686. or "volatile" values.
  687. 3. At final stage, you can retrieve statistics as how many (distinct)
  688. errors have been recorded, what was the duration of the whole test,
  689. how many times `update()` was called, etc.
  690. 4. Optionally, you can also call `format_report()` to get a nicely
  691. formatted report with list of arguments for each error string.
  692. 5. Since in bigger tests, argument lists can grow really large,
  693. complete lists are not normally printed. Instead, you can use
  694. `write_stats_csv()`, which will create one CSV per each error,
  695. named as first 7 chars of its SHA1 (inspired by Git).
  696. Note that you need to pass an existing writable folder path.
  697. """
  698. ##
  699. # internal methods
  700. #
  701. def __init__(self):
  702. self._start = time.time()
  703. self._db = {}
  704. self.tests_done = 0
  705. self.tests_passed = 0
  706. self.argsets_done = 0
  707. self.driver_stats = {}
  708. def _csv_fname(self, errstr, prefix):
  709. """Format name of file for this error string"""
  710. return '%s/%s.csv' % (prefix, self._eid(errstr))
  711. def _eid(self, errstr):
  712. """Return EID for the error string (first 7 chars of SHA1)."""
  713. return hashlib.sha1(errstr).hexdigest()[:7]
  714. def _insert(self, errstr, argset):
  715. """Insert the argset into DB."""
  716. if errstr not in self._db:
  717. self._db[errstr] = []
  718. self._db[errstr].append(argset)
  719. def _format_error(self, errstr, max_aa=0):
  720. """Format single error for output."""
  721. argsets_affected = self._db[errstr]
  722. num_aa = len(argsets_affected)
  723. # trim if list is too long for Jenkins
  724. argsets_shown = argsets_affected
  725. if max_aa and (num_aa > max_aa):
  726. div = ["[...] not showing %s cases, see %s.csv for full list"
  727. % (num_aa - max_aa, self._eid(errstr))]
  728. argsets_shown = argsets_affected[0:max_aa] + div
  729. # format error
  730. formatted_aa = "\n".join([str(arg) for arg in argsets_shown])
  731. return ("~~~ ERROR FOUND (%s) ~~~~~~~~~~~~~~~~~~~~~~~~~\n"
  732. "--- error string: -----------------------------------\n%s\n"
  733. "--- argsets affected (%d) ---------------------------\n%s\n"
  734. % (self._eid(errstr), errstr, num_aa, formatted_aa))
  735. ##
  736. # public methods
  737. #
  738. def errors_found(self):
  739. """Return number of non-distinct errors in db."""
  740. return bool(self._db)
  741. def format_report(self, max_aa=0):
  742. """Return complete report formatted as string."""
  743. error_list = "\n".join([self._format_error(e, max_aa=max_aa)
  744. for e in self._db])
  745. return ("Found %(total_errors)s (%(distinct_errors)s distinct) errors"
  746. " in %(tests_done)s tests with %(argsets)s argsets"
  747. " (duration: %(time)ss):"
  748. % self.getstats()
  749. + "\n\n" + error_list)
  750. def getstats(self):
  751. """Return basic and driver stats
  752. argsets_done - this should must be raised by outer code,
  753. once per each unique argset
  754. tests_done - how many times Tracker.update() was called
  755. distinct_errors - how many distinct errors (same `str(error)`)
  756. were seen by Tracker.update()
  757. total_errors - how many times `Tracker.update()` saw an
  758. error, i.e. how many argsets are in DB
  759. time - how long since init (seconds)
  760. """
  761. def total_errors():
  762. return reduce(lambda x, y: x + len(y), self._db.values(), 0)
  763. stats = {
  764. "argsets": self.argsets_done,
  765. "tests_done": self.tests_done,
  766. "distinct_errors": len(self._db),
  767. "total_errors": total_errors(),
  768. "time": int(time.time() - self._start)
  769. }
  770. stats.update(self.driver_stats)
  771. return stats
  772. def update(self, error, argset):
  773. """Update tracker with test result.
  774. If `bool(error)` is true, it is considered error and argset
  775. is inserted to DB with `str(error)` as key. This allows for later
  776. sorting and analysis.
  777. """
  778. self.tests_done += 1
  779. if error:
  780. errstr = str(error)
  781. self._insert(errstr, argset)
  782. def write_stats_csv(self, fname):
  783. """Write stats to a simple one row (plus header) CSV."""
  784. stats = self.getstats()
  785. colnames = sorted(stats.keys())
  786. with open(fname, 'a') as fh:
  787. cw = csv.DictWriter(fh, colnames)
  788. cw.writerow(dict(zip(colnames, colnames))) # header
  789. cw.writerow(stats)
  790. def write_args_csv(self, prefix=''):
  791. """Write out a set of CSV files, one per distinctive error.
  792. Each CSV is named with error EID (first 7 chars of SHA1) and lists
  793. all argument sets affected by this error. This is supposed to make
  794. easier to further analyse impact and trigerring values of errors,
  795. perhaps using a table processor software."""
  796. def get_all_colnames():
  797. cn = {}
  798. for affected in self._db.itervalues():
  799. for argset in affected:
  800. cn.update(dict.fromkeys(argset.keys()))
  801. return sorted(cn.keys())
  802. all_colnames = get_all_colnames()
  803. for errstr in self._db:
  804. with open(self._csv_fname(errstr, prefix), 'a') as fh:
  805. cw = csv.DictWriter(fh, all_colnames)
  806. cw.writerow(dict(zip(all_colnames, all_colnames))) # header
  807. for argset in self._db[errstr]:
  808. cw.writerow(argset)
  809. def dataMatch(pattern, data, rmax=10, _r=0):
  810. """Check if data structure matches a pattern data structure.
  811. Supports lists, dictionaries and scalars (int, float, string).
  812. For scalars, simple `==` is used. Lists are converted to sets and
  813. "to match" means "to have a matching subset (e.g. `[1, 2, 3, 4]`
  814. matches `[3, 2]`). Both lists and dictionaries are matched recursively.
  815. """
  816. def listMatch(pattern, data):
  817. """Match list-like objects"""
  818. assert all([hasattr(o, 'append') for o in [pattern, data]])
  819. results = []
  820. for pv in pattern:
  821. if any([dataMatch(pv, dv, _r=_r+1) for dv in data]):
  822. results.append(True)
  823. else:
  824. results.append(False)
  825. return all(results)
  826. def dictMatch(pattern, data):
  827. """Match dict-like objects"""
  828. assert all([hasattr(o, 'iteritems') for o in [pattern, data]])
  829. results = []
  830. try:
  831. for pk, pv in pattern.iteritems():
  832. results.append(dataMatch(pv, data[pk], _r=_r+1))
  833. except KeyError:
  834. results.append(False)
  835. return all(results)
  836. if _r == rmax:
  837. raise RuntimeError("recursion limit hit")
  838. result = None
  839. if pattern == data:
  840. result = True
  841. else:
  842. for handler in [dictMatch, listMatch]:
  843. try:
  844. result = handler(pattern, data)
  845. except AssertionError:
  846. continue
  847. return result
  848. def jsDump(data):
  849. """A human-readable JSON dump."""
  850. return json.dumps(data, sort_keys=True, indent=4,
  851. separators=(',', ': '))
  852. def jsDiff(dira, dirb, namea="A", nameb="B", chara="a", charb="b"):
  853. """JSON-based human-readable diff of two data structures.
  854. '''BETA''' version.
  855. jsDiff is based on unified diff of two human-readable JSON dumps except
  856. that instead of showing line numbers and context based on proximity to
  857. the changed lines, it prints only context important from the data
  858. structure point.
  859. The goal is to be able to quickly tell the story of what has changed
  860. where in the structure, no matter size and complexity of the data set.
  861. For example:
  862. a = {
  863. 'w': {1: 2, 3: 4},
  864. 'x': [1, 2, 3],
  865. 'y': [3, 1, 2]
  866. }
  867. b = {
  868. 'w': {1: 2, 3: 4},
  869. 'x': [1, 1, 3],
  870. 'y': [3, 1, 3]
  871. }
  872. print jsDiff(a, b)
  873. will output:
  874. aaa ~/A
  875. "x": [
  876. a 2,
  877. "y": [
  878. a 2
  879. bbb ~/B
  880. "x": [
  881. b 1,
  882. "y": [
  883. b 3
  884. Notice that the final output somehow resembles the traditional unified
  885. diff, so to avoid confusion, +/- is changed to a/b (the characters can
  886. be provided as well as the names A/B).
  887. """
  888. def compress(lines):
  889. def is_body(line):
  890. return line.startswith(("-", "+", " "))
  891. def is_diff(line):
  892. return line.startswith(("-", "+"))
  893. def is_diffA(line):
  894. return line.startswith("-")
  895. def is_diffB(line):
  896. return line.startswith("+")
  897. def is_context(line):
  898. return line.startswith(" ")
  899. def is_hdr(line):
  900. return line.startswith(("@@", "---", "+++"))
  901. def is_hdr_hunk(line):
  902. return line.startswith("@@")
  903. def is_hdr_A(line):
  904. return line.startswith("---")
  905. def is_hdr_B(line):
  906. return line.startswith("+++")
  907. class Level(object):
  908. def __init__(self, hint):
  909. self.hint = hint
  910. self.hinted = False
  911. def __str__(self):
  912. return str(self.hint)
  913. def get_hint(self):
  914. if not self.hinted:
  915. self.hinted = True
  916. return self.hint
  917. class ContextTracker(object):
  918. def __init__(self):
  919. self.trace = []
  920. self.last_line = None
  921. self.last_indent = -1
  922. def indent_of(self, line):
  923. meat = line[1:].lstrip(" ")
  924. ind = len(line) - len(meat) - 1
  925. return ind
  926. def check(self, line):
  927. indent = self.indent_of(line)
  928. if indent > self.last_indent:
  929. self.trace.append(Level(self.last_line))
  930. elif indent < self.last_indent:
  931. self.trace.pop()
  932. self.last_line = line
  933. self.last_indent = indent
  934. def get_hint(self):
  935. return self.trace[-1].get_hint()
  936. buffa = []
  937. buffb = []
  938. ct = ContextTracker()
  939. for line in lines:
  940. if is_hdr_hunk(line):
  941. continue
  942. elif is_hdr_A(line):
  943. line = line.replace("---", chara * 3, 1)
  944. buffa.insert(0, line)
  945. elif is_hdr_B(line):
  946. line = line.replace("+++", charb * 3, 1)
  947. buffb.insert(0, line)
  948. elif is_body(line):
  949. ct.check(line)
  950. if is_diff(line):
  951. hint = ct.get_hint()
  952. if hint:
  953. buffa.append(hint)
  954. buffb.append(hint)
  955. if is_diffA(line):
  956. line = line.replace("-", chara, 1)
  957. buffa.append(line)
  958. elif is_diffB(line):
  959. line = line.replace("+", charb, 1)
  960. buffb.append(line)
  961. else:
  962. raise AssertionError("difflib.unified_diff emited"
  963. " unknown format (%s chars):\n%s"
  964. % (len(line), line))
  965. return buffa + buffb
  966. dumpa = jsDump(dira)
  967. dumpb = jsDump(dirb)
  968. udiff = difflib.unified_diff(dumpa.split("\n"), dumpb.split("\n"),
  969. "~/" + namea, "~/" + nameb,
  970. n=10000, lineterm='')
  971. return "\n".join(compress([line for line in udiff]))
  972. class Cartman(object):
  973. """Create argument sets from ranges (or ay iterators) of values.
  974. This class is to enable easy definition and generation of dictionary
  975. argument sets using Cartesian product. You only need to define:
  976. * structure of argument set (can be more than just flat dict)
  977. * ranges, or arbitrary iterators of values on each "leaf" of the
  978. argument set
  979. Since there is expectation that any argument can have any kind of values
  980. even another iterables, the pure logic "iterate it if you can"
  981. is insufficient. Instead, definition is divided in two parts:
  982. * scheme, which is a "prototype" of a final argument set, except
  983. that for each value that will change, a `Cartman.Iterable`
  984. sentinel is used. For each leaf that is constant, `Cartman.Scalar`
  985. is used
  986. * source, which has the same structure, except that where in scheme
  987. is `Iterable`, an iterable object is expected, whereas in places
  988. where `Scalar` is used, a value is assigned that does not change
  989. during iteration.
  990. Finally, when such instance is used in loop, argument sets are generated
  991. uising Cartesian product of each iterable found. This allows for
  992. relatively easy definition of complex scenarios.
  993. Consider this example:
  994. You have a system (wrapped up in test driver) that takes ''size''
  995. argument, that is supposed to be ''width'', ''height'' and ''depth'',
  996. each an integer ranging from 1 to 100, and ''color'' that can
  997. be "white", "black" or "yellow".
  998. For a test using all-combinations strategy, you will need to generate
  999. 100 * 100 * 100 * 3 argument sets, i.e. 3M tests.
  1000. All you need to do is:
  1001. scheme = {
  1002. 'size': {
  1003. 'width': Cartman.Iterable,
  1004. 'height': Cartman.Iterable,
  1005. 'depth': Cartman.Iterable,
  1006. }
  1007. 'color': Cartman.Iterable,
  1008. }
  1009. source = {
  1010. 'size': {
  1011. 'width': range(1, 100),
  1012. 'height': range(1, 100),
  1013. 'depth': range(1, 100),
  1014. }
  1015. 'color': ['white', 'black', 'yellow'],
  1016. }
  1017. c = Cartman(source, scheme)
  1018. for argset in c:
  1019. result = my_test(argset)
  1020. # assert ...
  1021. The main advantage is that you can separate the definition from
  1022. the code, and you can keep yor iterators as big or as small as
  1023. needed, and add / remove values.
  1024. Also in case your parameters vary in structure over time, or from
  1025. one test to another, it gets much easier to keep up with changes
  1026. without much jumping through hoops.
  1027. Note: `Cartman.Scalar` is provided mainly to make your definitions
  1028. more readable. Following constructions are functionally equal:
  1029. c = Cartman({'a': 1}, {'a': Cartman.Scalar})
  1030. c = Cartman({'a': [1]}, {'a': Cartman.Iterable})
  1031. In future, however, this might change, though, mainly in case
  1032. optimization became possible based on what was used.
  1033. """
  1034. # TODO: support for arbitrary ordering (profile / nginx)
  1035. # TODO: implement getstats and fmtstats
  1036. # TODO: N-wise
  1037. class _BaseMark(object):
  1038. pass
  1039. class Scalar(_BaseMark):
  1040. pass
  1041. class Iterable(_BaseMark):
  1042. pass
  1043. def __init__(self, source, scheme, recursion_limit=10, _r=0):
  1044. self.source = source
  1045. self.scheme = scheme
  1046. self.recursion_limit = recursion_limit
  1047. self._r = _r
  1048. if self._r > self.recursion_limit:
  1049. raise RuntimeError("recursion limit exceeded")
  1050. # validate scheme + source and throw useful error
  1051. scheme_ok = isinstance(self.scheme, collections.Mapping)
  1052. source_ok = isinstance(self.source, collections.Mapping)
  1053. if not scheme_ok:
  1054. raise ValueError("scheme must be a mapping (e.g. dict)")
  1055. elif scheme_ok and not source_ok:
  1056. raise ValueError("scheme vs. source mismatch")
  1057. def __deepcopy__(self, memo):
  1058. return Cartman(deepcopy(self.source, memo),
  1059. deepcopy(self.scheme, memo))
  1060. def _is_mark(self, subscheme):
  1061. try:
  1062. return issubclass(subscheme, Cartman._BaseMark)
  1063. except TypeError:
  1064. return False
  1065. def _means_scalar(self, subscheme):
  1066. if self._is_mark(subscheme):
  1067. return issubclass(subscheme, Cartman.Scalar)
  1068. def _means_iterable(self, subscheme):
  1069. if self._is_mark(subscheme):
  1070. return issubclass(subscheme, Cartman.Iterable)
  1071. def _get_iterable_for(self, key):
  1072. subscheme = self.scheme[key]
  1073. subsource = self.source[key]
  1074. if self._means_scalar(subscheme):
  1075. return [subsource]
  1076. elif self._means_iterable(subscheme):
  1077. return subsource
  1078. else: # try to use it as scheme
  1079. return iter(Cartman(subsource, subscheme, _r=self._r+1))
  1080. def __iter__(self):
  1081. names = []
  1082. iterables = []
  1083. keys = self.scheme.keys()
  1084. for key in keys:
  1085. try:
  1086. iterables.append(self._get_iterable_for(key))
  1087. except KeyError:
  1088. pass # ignore that subsource mentioned by scheme is missing
  1089. else:
  1090. names.append(key)
  1091. for values in itertools.product(*iterables):
  1092. yield dict(zip(names, values))
  1093. def getstats(self):
  1094. return {}
  1095. def fmtstats(self):
  1096. return ""