runtests.py 18 KB

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