collection of python libs developed for testing purposes

hoover.py 50KB

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