looponfail.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. """
  2. Implement -f aka looponfailing for pytest.
  3. NOTE that we try to avoid loading and depending on application modules
  4. within the controlling process (the one that starts repeatedly test
  5. processes) otherwise changes to source code can crash
  6. the controlling process which should best never happen.
  7. """
  8. from __future__ import print_function
  9. import py
  10. import pytest
  11. import sys
  12. import time
  13. import execnet
  14. def pytest_addoption(parser):
  15. group = parser.getgroup("xdist", "distributed and subprocess testing")
  16. group._addoption(
  17. "-f",
  18. "--looponfail",
  19. action="store_true",
  20. dest="looponfail",
  21. default=False,
  22. help="run tests in subprocess, wait for modified files "
  23. "and re-run failing test set until all pass.",
  24. )
  25. def pytest_cmdline_main(config):
  26. if config.getoption("looponfail"):
  27. usepdb = config.getoption("usepdb") # a core option
  28. if usepdb:
  29. raise pytest.UsageError("--pdb incompatible with --looponfail.")
  30. looponfail_main(config)
  31. return 2 # looponfail only can get stop with ctrl-C anyway
  32. def looponfail_main(config):
  33. remotecontrol = RemoteControl(config)
  34. rootdirs = config.getini("looponfailroots")
  35. statrecorder = StatRecorder(rootdirs)
  36. try:
  37. while 1:
  38. remotecontrol.loop_once()
  39. if not remotecontrol.failures and remotecontrol.wasfailing:
  40. # the last failures passed, let's immediately rerun all
  41. continue
  42. repr_pytest_looponfailinfo(
  43. failreports=remotecontrol.failures, rootdirs=rootdirs
  44. )
  45. statrecorder.waitonchange(checkinterval=2.0)
  46. except KeyboardInterrupt:
  47. print()
  48. class RemoteControl(object):
  49. def __init__(self, config):
  50. self.config = config
  51. self.failures = []
  52. def trace(self, *args):
  53. if self.config.option.debug:
  54. msg = " ".join([str(x) for x in args])
  55. print("RemoteControl:", msg)
  56. def initgateway(self):
  57. return execnet.makegateway("popen")
  58. def setup(self, out=None):
  59. if out is None:
  60. out = py.io.TerminalWriter()
  61. if hasattr(self, "gateway"):
  62. raise ValueError("already have gateway %r" % self.gateway)
  63. self.trace("setting up worker session")
  64. self.gateway = self.initgateway()
  65. self.channel = channel = self.gateway.remote_exec(
  66. init_worker_session,
  67. args=self.config.args,
  68. option_dict=vars(self.config.option),
  69. )
  70. remote_outchannel = channel.receive()
  71. def write(s):
  72. out._file.write(s)
  73. out._file.flush()
  74. remote_outchannel.setcallback(write)
  75. def ensure_teardown(self):
  76. if hasattr(self, "channel"):
  77. if not self.channel.isclosed():
  78. self.trace("closing", self.channel)
  79. self.channel.close()
  80. del self.channel
  81. if hasattr(self, "gateway"):
  82. self.trace("exiting", self.gateway)
  83. self.gateway.exit()
  84. del self.gateway
  85. def runsession(self):
  86. try:
  87. self.trace("sending", self.failures)
  88. self.channel.send(self.failures)
  89. try:
  90. return self.channel.receive()
  91. except self.channel.RemoteError:
  92. e = sys.exc_info()[1]
  93. self.trace("ERROR", e)
  94. raise
  95. finally:
  96. self.ensure_teardown()
  97. def loop_once(self):
  98. self.setup()
  99. self.wasfailing = self.failures and len(self.failures)
  100. result = self.runsession()
  101. failures, reports, collection_failed = result
  102. if collection_failed:
  103. pass # "Collection failed, keeping previous failure set"
  104. else:
  105. uniq_failures = []
  106. for failure in failures:
  107. if failure not in uniq_failures:
  108. uniq_failures.append(failure)
  109. self.failures = uniq_failures
  110. def repr_pytest_looponfailinfo(failreports, rootdirs):
  111. tr = py.io.TerminalWriter()
  112. if failreports:
  113. tr.sep("#", "LOOPONFAILING", bold=True)
  114. for report in failreports:
  115. if report:
  116. tr.line(report, red=True)
  117. tr.sep("#", "waiting for changes", bold=True)
  118. for rootdir in rootdirs:
  119. tr.line("### Watching: %s" % (rootdir,), bold=True)
  120. def init_worker_session(channel, args, option_dict):
  121. import os
  122. import sys
  123. outchannel = channel.gateway.newchannel()
  124. sys.stdout = sys.stderr = outchannel.makefile("w")
  125. channel.send(outchannel)
  126. # prune sys.path to not contain relative paths
  127. newpaths = []
  128. for p in sys.path:
  129. if p:
  130. if not os.path.isabs(p):
  131. p = os.path.abspath(p)
  132. newpaths.append(p)
  133. sys.path[:] = newpaths
  134. # fullwidth, hasmarkup = channel.receive()
  135. from _pytest.config import Config
  136. config = Config.fromdictargs(option_dict, list(args))
  137. config.args = args
  138. from xdist.looponfail import WorkerFailSession
  139. WorkerFailSession(config, channel).main()
  140. class WorkerFailSession(object):
  141. def __init__(self, config, channel):
  142. self.config = config
  143. self.channel = channel
  144. self.recorded_failures = []
  145. self.collection_failed = False
  146. config.pluginmanager.register(self)
  147. config.option.looponfail = False
  148. config.option.usepdb = False
  149. def DEBUG(self, *args):
  150. if self.config.option.debug:
  151. print(" ".join(map(str, args)))
  152. def pytest_collection(self, session):
  153. self.session = session
  154. self.trails = self.current_command
  155. hook = self.session.ihook
  156. try:
  157. items = session.perform_collect(self.trails or None)
  158. except pytest.UsageError:
  159. items = session.perform_collect(None)
  160. hook.pytest_collection_modifyitems(
  161. session=session, config=session.config, items=items
  162. )
  163. hook.pytest_collection_finish(session=session)
  164. return True
  165. def pytest_runtest_logreport(self, report):
  166. if report.failed:
  167. self.recorded_failures.append(report)
  168. def pytest_collectreport(self, report):
  169. if report.failed:
  170. self.recorded_failures.append(report)
  171. self.collection_failed = True
  172. def main(self):
  173. self.DEBUG("WORKER: received configuration, waiting for command trails")
  174. try:
  175. command = self.channel.receive()
  176. except KeyboardInterrupt:
  177. return # in the worker we can't do much about this
  178. self.DEBUG("received", command)
  179. self.current_command = command
  180. self.config.hook.pytest_cmdline_main(config=self.config)
  181. trails, failreports = [], []
  182. for rep in self.recorded_failures:
  183. trails.append(rep.nodeid)
  184. loc = rep.longrepr
  185. loc = str(getattr(loc, "reprcrash", loc))
  186. failreports.append(loc)
  187. self.channel.send((trails, failreports, self.collection_failed))
  188. class StatRecorder(object):
  189. def __init__(self, rootdirlist):
  190. self.rootdirlist = rootdirlist
  191. self.statcache = {}
  192. self.check() # snapshot state
  193. def fil(self, p):
  194. return p.check(file=1, dotfile=0) and p.ext != ".pyc"
  195. def rec(self, p):
  196. return p.check(dotfile=0)
  197. def waitonchange(self, checkinterval=1.0):
  198. while 1:
  199. changed = self.check()
  200. if changed:
  201. return
  202. time.sleep(checkinterval)
  203. def check(self, removepycfiles=True): # noqa, too complex
  204. changed = False
  205. statcache = self.statcache
  206. newstat = {}
  207. for rootdir in self.rootdirlist:
  208. for path in rootdir.visit(self.fil, self.rec):
  209. oldstat = statcache.pop(path, None)
  210. try:
  211. newstat[path] = curstat = path.stat()
  212. except py.error.ENOENT:
  213. if oldstat:
  214. changed = True
  215. else:
  216. if oldstat:
  217. if (
  218. oldstat.mtime != curstat.mtime
  219. or oldstat.size != curstat.size
  220. ):
  221. changed = True
  222. print("# MODIFIED", path)
  223. if removepycfiles and path.ext == ".py":
  224. pycfile = path + "c"
  225. if pycfile.check():
  226. pycfile.remove()
  227. else:
  228. changed = True
  229. if statcache:
  230. changed = True
  231. self.statcache = newstat
  232. return changed