runtests.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. #!/usr/bin/env python3
  2. from __future__ import print_function, unicode_literals
  3. import argparse
  4. import coverage
  5. import json
  6. import logging
  7. import multiprocessing
  8. import os
  9. import shutil
  10. import subprocess
  11. import sys
  12. import threading
  13. import time
  14. RUNNER_PY = "nosetests"
  15. RUNNER_PY2 = "nosetests-2"
  16. RUNNER_PY3 = "nosetests-3"
  17. COVER_PY = "coverage"
  18. COVER_PY2 = "coverage2"
  19. COVER_PY3 = "coverage3"
  20. LASTLEN = None
  21. NUMREMAINING = None
  22. PRINTLOCK = None
  23. RUNNING = []
  24. FAILED = []
  25. NUMPROCS = multiprocessing.cpu_count() - 1
  26. if os.environ.get('BUILD_ID'):
  27. NUMPROCS = multiprocessing.cpu_count()
  28. LOG = logging.getLogger(__name__)
  29. def setup_parser():
  30. """ Set up the command line arguments supported and return the arguments
  31. """
  32. parser = argparse.ArgumentParser(description="Run the Pagure tests")
  33. parser.add_argument(
  34. "--debug",
  35. dest="debug",
  36. action="store_true",
  37. default=False,
  38. help="Increase the level of data logged.",
  39. )
  40. subparsers = parser.add_subparsers(title="actions")
  41. # RUN
  42. parser_run = subparsers.add_parser("run", help="Run the tests")
  43. parser_run.add_argument(
  44. "--py2",
  45. dest="py2",
  46. action="store_true",
  47. default=False,
  48. help="Runs the tests only in python2 instead of both python2 and python3",
  49. )
  50. parser_run.add_argument(
  51. "--py3",
  52. dest="py3",
  53. action="store_true",
  54. default=False,
  55. help="Runs the tests only in python3 instead of both python2 and python3",
  56. )
  57. parser_run.add_argument(
  58. "--results",
  59. default="results",
  60. help="Specify a folder in which the results should be placed "
  61. "(defaults to `results`)",
  62. )
  63. parser_run.add_argument(
  64. "-f",
  65. "--force",
  66. default=False,
  67. action="store_true",
  68. help="Override the results and newfailed file without asking you",
  69. )
  70. parser_run.add_argument(
  71. "--with-coverage",
  72. default=False,
  73. action="store_true",
  74. help="Also build coverage report",
  75. )
  76. parser_run.add_argument(
  77. "failed_tests",
  78. nargs="?",
  79. help="File containing a JSON list of the failed tests to run or "
  80. "pointing to a test file to run.",
  81. )
  82. parser_run.set_defaults(func=do_run)
  83. # RERUN
  84. parser_run = subparsers.add_parser("rerun", help="Run failed tests")
  85. parser_run.add_argument(
  86. "--debug",
  87. dest="debug",
  88. action="store_true",
  89. default=False,
  90. help="Expand the level of data returned.",
  91. )
  92. parser_run.add_argument(
  93. "--py2",
  94. dest="py2",
  95. action="store_true",
  96. default=False,
  97. help="Runs the tests only in python2 instead of both python2 and python3",
  98. )
  99. parser_run.add_argument(
  100. "--py3",
  101. dest="py3",
  102. action="store_true",
  103. default=False,
  104. help="Runs the tests only in python3 instead of both python2 and python3",
  105. )
  106. parser_run.add_argument(
  107. "--results",
  108. default="results",
  109. help="Specify a folder in which the results should be placed "
  110. "(defaults to `results`)",
  111. )
  112. parser_run.add_argument(
  113. "--with-coverage",
  114. default=False,
  115. action="store_true",
  116. help="Also build coverage report",
  117. )
  118. parser_run.set_defaults(func=do_rerun)
  119. # LIST
  120. parser_run = subparsers.add_parser("list", help="List failed tests")
  121. parser_run.add_argument(
  122. "--results",
  123. default="results",
  124. help="Specify a folder in which the results should be placed "
  125. "(defaults to `results`)",
  126. )
  127. parser_run.add_argument(
  128. "--show",
  129. default=False,
  130. action="store_true",
  131. help="Show the error files using `less`",
  132. )
  133. parser_run.add_argument(
  134. "-n", default=None, nargs="?", type=int,
  135. help="Number of failed test to show",
  136. )
  137. parser_run.set_defaults(func=do_list)
  138. # SHOW-COVERAGE
  139. parser_run = subparsers.add_parser(
  140. "show-coverage",
  141. help="Shows the coverage report from the data in the results folder")
  142. parser_run.add_argument(
  143. "--debug",
  144. dest="debug",
  145. action="store_true",
  146. default=False,
  147. help="Expand the level of data returned.",
  148. )
  149. parser_run.add_argument(
  150. "--py2",
  151. dest="py2",
  152. action="store_true",
  153. default=False,
  154. help="Runs the tests only in python2 instead of both python2 and python3",
  155. )
  156. parser_run.add_argument(
  157. "--py3",
  158. dest="py3",
  159. action="store_true",
  160. default=False,
  161. help="Runs the tests only in python3 instead of both python2 and python3",
  162. )
  163. parser_run.add_argument(
  164. "--results",
  165. default="results",
  166. help="Specify a folder in which the results should be placed "
  167. "(defaults to `results`)",
  168. )
  169. parser_run.set_defaults(func=do_show_coverage)
  170. return parser
  171. def clean_line():
  172. global LASTLEN
  173. with PRINTLOCK:
  174. if LASTLEN is not None:
  175. print(" " * LASTLEN, end="\r")
  176. LASTLEN = None
  177. def print_running():
  178. global LASTLEN
  179. with PRINTLOCK:
  180. msg = "Running %d suites: %d remaining, %d failed" % (
  181. len(RUNNING),
  182. NUMREMAINING,
  183. len(FAILED),
  184. )
  185. LASTLEN = len(msg)
  186. print(msg, end="\r")
  187. def add_running(suite):
  188. global NUMREMAINING
  189. with PRINTLOCK:
  190. NUMREMAINING -= 1
  191. RUNNING.append(suite)
  192. clean_line()
  193. print_running()
  194. def remove_running(suite, failed):
  195. with PRINTLOCK:
  196. RUNNING.remove(suite)
  197. clean_line()
  198. status = 'passed'
  199. if failed:
  200. status = 'FAILED'
  201. print("Test suite %s: %s" % (status, suite))
  202. print_running()
  203. class WorkerThread(threading.Thread):
  204. def __init__(self, sem, pyver, suite, results, with_cover):
  205. name = "py%s-%s" % (pyver, suite)
  206. super(WorkerThread, self).__init__(name="worker-%s" % name)
  207. self.name = name
  208. self.sem = sem
  209. self.pyver = pyver
  210. self.suite = suite
  211. self.failed = None
  212. self.results = results
  213. self.with_cover = with_cover
  214. def run(self):
  215. with self.sem:
  216. add_running(self.name)
  217. with open(os.path.join(self.results, self.name), "w") as resfile:
  218. if self.pyver == 2:
  219. runner = RUNNER_PY2
  220. elif self.pyver == 3:
  221. runner = RUNNER_PY3
  222. else:
  223. runner = RUNNER_PY
  224. cmd = [runner, "-v", "tests.%s" % self.suite]
  225. if self.with_cover:
  226. cmd.append("--with-cover")
  227. env = os.environ.copy()
  228. env.update({
  229. "PAGURE_CONFIG": "../tests/test_config",
  230. "COVERAGE_FILE": os.path.join(
  231. self.results, "%s.coverage" % self.name
  232. ),
  233. "LANG": "en_US.UTF-8",
  234. })
  235. proc = subprocess.Popen(
  236. cmd, cwd=".", stdout=resfile, stderr=subprocess.STDOUT, env=env
  237. )
  238. res = proc.wait()
  239. if res == 0:
  240. self.failed = False
  241. else:
  242. self.failed = True
  243. if not self.failed is not True:
  244. with PRINTLOCK:
  245. FAILED.append(self.name)
  246. remove_running(self.name, self.failed)
  247. def do_run(args):
  248. """ Performs some checks and runs the tests.
  249. """
  250. # Some pre-flight checks
  251. if not os.path.exists("./.git") or not os.path.exists("./nosetests3"):
  252. print("Please run from a single level into the Pagure codebase")
  253. return 1
  254. if os.path.exists(args.results):
  255. if not args.force:
  256. print(
  257. "Results folder exists, please remove it so we do not clobber"
  258. " or use --force"
  259. )
  260. return 1
  261. else:
  262. shutil.rmtree(args.results)
  263. os.mkdir(args.results)
  264. print("Pre-flight checks passed")
  265. suites = []
  266. if args.failed_tests:
  267. here = os.path.join(os.path.dirname(os.path.abspath(__file__)))
  268. failed_tests_fullpath = os.path.join(here, args.failed_tests)
  269. if not os.path.exists(failed_tests_fullpath):
  270. print("Could not find the specified file:%s" % failed_tests_fullpath)
  271. return 1
  272. print("Loading failed tests")
  273. try:
  274. with open(failed_tests_fullpath, "r") as ffile:
  275. suites = json.loads(ffile.read())
  276. except json.decoder.JSONDecodeError:
  277. bname = os.path.basename(args.failed_tests)
  278. if bname.endswith(".py") and bname.startswith("test_"):
  279. suites.append(bname.replace(".py", ""))
  280. if len(suites) == 0:
  281. print("Loading all tests")
  282. for fname in os.listdir("./tests"):
  283. if not fname.endswith(".py"):
  284. continue
  285. if not fname.startswith("test_"):
  286. continue
  287. suites.append(fname.replace(".py", ""))
  288. return _run_test_suites(args, suites)
  289. def do_rerun(args):
  290. """ Re-run tests that failed the last/specified run.
  291. """
  292. # Some pre-flight checks
  293. if not os.path.exists("./.git") or not os.path.exists("./pagure"):
  294. print("Please run from a single level into the Pagure codebase")
  295. return 1
  296. if not os.path.exists(args.results):
  297. print("Could not find an existing results folder at: %s" % args.results)
  298. return 1
  299. if not os.path.exists(os.path.join(args.results, "newfailed")):
  300. print(
  301. "Could not find an failed tests in the results folder at: %s" % args.results
  302. )
  303. return 1
  304. print("Pre-flight checks passed")
  305. suites = []
  306. tmp = []
  307. print("Loading failed tests")
  308. try:
  309. with open(os.path.join(args.results, "newfailed"), "r") as ffile:
  310. tmp = json.loads(ffile.read())
  311. except json.decoder.JSONDecodeError:
  312. print("File containing the failed tests is not JSON")
  313. return 1
  314. for suite in tmp:
  315. if suite.startswith(("py2-", "py3-")):
  316. suites.append(suite[4:])
  317. return _run_test_suites(args, set(suites))
  318. def _get_pyvers(args):
  319. pyvers = [2, 3]
  320. if args.py2:
  321. pyvers = [2,]
  322. elif args.py3:
  323. pyvers = [3,]
  324. un_versioned = False
  325. try:
  326. subprocess.check_call(["which", RUNNER_PY])
  327. un_versioned = True
  328. except subprocess.CalledProcessError:
  329. print("No %s found no unversioned runner" % RUNNER_PY)
  330. if 2 in pyvers:
  331. nopy2 = False
  332. try:
  333. subprocess.check_call(["which", RUNNER_PY2])
  334. except subprocess.CalledProcessError:
  335. print("No %s found, removing python 2" % RUNNER_PY2)
  336. del pyvers[pyvers.index(2)]
  337. if 3 in pyvers:
  338. nopy3 = False
  339. try:
  340. subprocess.check_call(["which", RUNNER_PY3])
  341. except subprocess.CalledProcessError:
  342. print("No %s found, removing python 3" % RUNNER_PY3)
  343. del pyvers[pyvers.index(3)]
  344. if not pyvers and un_versioned:
  345. pyvers = [""]
  346. return pyvers
  347. def _run_test_suites(args, suites):
  348. print("Using %d processes" % NUMPROCS)
  349. print("Start timing")
  350. start = time.time()
  351. global PRINTLOCK
  352. PRINTLOCK = threading.RLock()
  353. global NUMREMAINING
  354. NUMREMAINING = 0
  355. sem = threading.BoundedSemaphore(NUMPROCS)
  356. # Create a worker per test
  357. workers = {}
  358. pyvers = _get_pyvers(args)
  359. if not pyvers:
  360. return 1
  361. for suite in suites:
  362. for pyver in pyvers:
  363. NUMREMAINING += 1
  364. workers["py%s-%s" % (pyver, suite)] = WorkerThread(
  365. sem, pyver, suite, args.results, args.with_coverage
  366. )
  367. # Start the workers
  368. print("Starting the workers")
  369. print()
  370. print()
  371. for worker in workers.values():
  372. worker.start()
  373. # Wait for them to terminate
  374. for worker in workers:
  375. workers[worker].join()
  376. print_running()
  377. print()
  378. print("All work done")
  379. # Gather results
  380. print()
  381. print()
  382. if FAILED:
  383. print("Failed tests:")
  384. for worker in workers:
  385. if not workers[worker].failed:
  386. continue
  387. print("FAILED test: %s" % (worker))
  388. # Write failed
  389. if FAILED:
  390. with open(os.path.join(args.results, "newfailed"), "w") as ffile:
  391. ffile.write(json.dumps(FAILED))
  392. # Exit
  393. outcode = 0
  394. if len(FAILED) == 0:
  395. print("ALL PASSED! CONGRATULATIONS!")
  396. else:
  397. outcode = 1
  398. # Stats
  399. end = time.time()
  400. print()
  401. print()
  402. print(
  403. "Ran %d tests in %f seconds, of which %d failed"
  404. % (len(workers), (end - start), len(FAILED))
  405. )
  406. if outcode == 0 and args.with_coverage:
  407. do_show_coverage(args)
  408. return outcode
  409. def do_list(args):
  410. """ List tests that failed the last/specified run.
  411. """
  412. # Some pre-flight checks
  413. if not os.path.exists("./.git") or not os.path.exists("./pagure"):
  414. print("Please run from a single level into the Pagure codebase")
  415. return 1
  416. if not os.path.exists(args.results):
  417. print("Could not find an existing results folder at: %s" % args.results)
  418. return 1
  419. if not os.path.exists(os.path.join(args.results, "newfailed")):
  420. print(
  421. "Could not find an failed tests in the results folder at: %s" % args.results
  422. )
  423. return 1
  424. print("Pre-flight checks passed")
  425. suites = []
  426. tmp = []
  427. print("Loading failed tests")
  428. try:
  429. with open(os.path.join(args.results, "newfailed"), "r") as ffile:
  430. suites = json.loads(ffile.read())
  431. except json.decoder.JSONDecodeError:
  432. print("File containing the failed tests is not JSON")
  433. return 1
  434. print("Failed tests")
  435. failed_tests = len(suites)
  436. if args.n:
  437. suites = suites[:args.n]
  438. print("- " + "\n- ".join(suites))
  439. print("Total: %s test failed" % failed_tests)
  440. if args.show:
  441. for suite in suites:
  442. cmd = ["less", os.path.join(args.results, suite)]
  443. subprocess.check_call(cmd)
  444. def do_show_coverage(args):
  445. print()
  446. print("Combining coverage results...")
  447. pyvers = _get_pyvers(args)
  448. for pyver in pyvers:
  449. coverfiles = []
  450. for fname in os.listdir(args.results):
  451. if fname.endswith(".coverage") and fname.startswith("py%s-" % pyver):
  452. coverfiles.append(os.path.join(args.results, fname))
  453. cover = None
  454. if pyver == 2:
  455. cover = COVER_PY2
  456. elif pyver == 3:
  457. cover = COVER_PY3
  458. else:
  459. cover = COVER_PY
  460. env = {"COVERAGE_FILE": os.path.join(args.results, "combined.coverage")}
  461. cmd = [cover, "combine"] + coverfiles
  462. subprocess.check_call(cmd, env=env)
  463. print()
  464. print("Python %s coverage: " % pyver)
  465. cmd = [cover, "report", "--include=./pagure/*", "-m"]
  466. subprocess.check_call(cmd, env=env)
  467. def main():
  468. """ Main function """
  469. # Set up parser for global args
  470. parser = setup_parser()
  471. # Parse the commandline
  472. try:
  473. arg = parser.parse_args()
  474. except argparse.ArgumentTypeError as err:
  475. print("\nError: {0}".format(err))
  476. return 2
  477. logging.basicConfig()
  478. if arg.debug:
  479. LOG.setLevel(logging.DEBUG)
  480. if "func" not in arg:
  481. parser.print_help()
  482. return 1
  483. arg.results = os.path.abspath(arg.results)
  484. return_code = 0
  485. try:
  486. return_code = arg.func(arg)
  487. except KeyboardInterrupt:
  488. print("\nInterrupted by user.")
  489. return_code = 1
  490. except Exception as err:
  491. print("Error: {0}".format(err))
  492. logging.exception("Generic error caught:")
  493. return_code = 5
  494. return return_code
  495. if __name__ == "__main__":
  496. sys.exit(main())