runtests.py 18 KB

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