autotry.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. # This Source Code Form is subject to the terms of the Mozilla Public
  2. # License, v. 2.0. If a copy of the MPL was not distributed with this
  3. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
  4. import argparse
  5. import itertools
  6. import os
  7. import re
  8. import subprocess
  9. import sys
  10. import which
  11. from collections import defaultdict
  12. import ConfigParser
  13. def arg_parser():
  14. parser = argparse.ArgumentParser()
  15. parser.add_argument('paths', nargs='*', help='Paths to search for tests to run on try.')
  16. parser.add_argument('-b', '--build', dest='builds', default='do',
  17. help='Build types to run (d for debug, o for optimized).')
  18. parser.add_argument('-p', '--platform', dest='platforms', action='append',
  19. help='Platforms to run (required if not found in the environment as AUTOTRY_PLATFORM_HINT).')
  20. parser.add_argument('-u', '--unittests', dest='tests', action='append',
  21. help='Test suites to run in their entirety.')
  22. parser.add_argument('-t', '--talos', dest='talos', action='append',
  23. help='Talos suites to run.')
  24. parser.add_argument('--tag', dest='tags', action='append',
  25. help='Restrict tests to the given tag (may be specified multiple times).')
  26. parser.add_argument('--and', action='store_true', dest='intersection',
  27. help='When -u and paths are supplied run only the intersection of the tests specified by the two arguments.')
  28. parser.add_argument('--no-push', dest='push', action='store_false',
  29. help='Do not push to try as a result of running this command (if '
  30. 'specified this command will only print calculated try '
  31. 'syntax and selection info).')
  32. parser.add_argument('--save', dest='save', action='store',
  33. help='Save the command line arguments for future use with --preset.')
  34. parser.add_argument('--preset', dest='load', action='store',
  35. help='Load a saved set of arguments. Additional arguments will override saved ones.')
  36. parser.add_argument('--list', action='store_true',
  37. help='List all saved try strings')
  38. parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', default=False,
  39. help='Print detailed information about the resulting test selection '
  40. 'and commands performed.')
  41. for arg, opts in AutoTry.pass_through_arguments.items():
  42. parser.add_argument(arg, **opts)
  43. return parser
  44. class TryArgumentTokenizer(object):
  45. symbols = [("seperator", ","),
  46. ("list_start", "\["),
  47. ("list_end", "\]"),
  48. ("item", "([^,\[\]\s][^,\[\]]+)"),
  49. ("space", "\s+")]
  50. token_re = re.compile("|".join("(?P<%s>%s)" % item for item in symbols))
  51. def tokenize(self, data):
  52. for match in self.token_re.finditer(data):
  53. symbol = match.lastgroup
  54. data = match.group(symbol)
  55. if symbol == "space":
  56. pass
  57. else:
  58. yield symbol, data
  59. class TryArgumentParser(object):
  60. """Simple three-state parser for handling expressions
  61. of the from "foo[sub item, another], bar,baz". This takes
  62. input from the TryArgumentTokenizer and runs through a small
  63. state machine, returning a dictionary of {top-level-item:[sub_items]}
  64. i.e. the above would result in
  65. {"foo":["sub item", "another"], "bar": [], "baz": []}
  66. In the case of invalid input a ValueError is raised."""
  67. EOF = object()
  68. def __init__(self):
  69. self.reset()
  70. def reset(self):
  71. self.tokens = None
  72. self.current_item = None
  73. self.data = {}
  74. self.token = None
  75. self.state = None
  76. def parse(self, tokens):
  77. self.reset()
  78. self.tokens = tokens
  79. self.consume()
  80. self.state = self.item_state
  81. while self.token[0] != self.EOF:
  82. self.state()
  83. return self.data
  84. def consume(self):
  85. try:
  86. self.token = self.tokens.next()
  87. except StopIteration:
  88. self.token = (self.EOF, None)
  89. def expect(self, *types):
  90. if self.token[0] not in types:
  91. raise ValueError("Error parsing try string, unexpected %s" % (self.token[0]))
  92. def item_state(self):
  93. self.expect("item")
  94. value = self.token[1].strip()
  95. if value not in self.data:
  96. self.data[value] = []
  97. self.current_item = value
  98. self.consume()
  99. if self.token[0] == "seperator":
  100. self.consume()
  101. elif self.token[0] == "list_start":
  102. self.consume()
  103. self.state = self.subitem_state
  104. elif self.token[0] == self.EOF:
  105. pass
  106. else:
  107. raise ValueError
  108. def subitem_state(self):
  109. self.expect("item")
  110. value = self.token[1].strip()
  111. self.data[self.current_item].append(value)
  112. self.consume()
  113. if self.token[0] == "seperator":
  114. self.consume()
  115. elif self.token[0] == "list_end":
  116. self.consume()
  117. self.state = self.after_list_end_state
  118. else:
  119. raise ValueError
  120. def after_list_end_state(self):
  121. self.expect("seperator")
  122. self.consume()
  123. self.state = self.item_state
  124. def parse_arg(arg):
  125. tokenizer = TryArgumentTokenizer()
  126. parser = TryArgumentParser()
  127. return parser.parse(tokenizer.tokenize(arg))
  128. class AutoTry(object):
  129. # Maps from flavors to the job names needed to run that flavour
  130. flavor_jobs = {
  131. 'mochitest': ['mochitest-1', 'mochitest-e10s-1'],
  132. 'xpcshell': ['xpcshell'],
  133. 'chrome': ['mochitest-o'],
  134. 'browser-chrome': ['mochitest-browser-chrome-1',
  135. 'mochitest-e10s-browser-chrome-1'],
  136. 'devtools-chrome': ['mochitest-devtools-chrome-1',
  137. 'mochitest-e10s-devtools-chrome-1'],
  138. 'crashtest': ['crashtest', 'crashtest-e10s'],
  139. 'reftest': ['reftest', 'reftest-e10s'],
  140. 'web-platform-tests': ['web-platform-tests-1'],
  141. }
  142. flavor_suites = {
  143. "mochitest": "mochitests",
  144. "xpcshell": "xpcshell",
  145. "chrome": "mochitest-o",
  146. "browser-chrome": "mochitest-bc",
  147. "devtools-chrome": "mochitest-dt",
  148. "crashtest": "crashtest",
  149. "reftest": "reftest",
  150. "web-platform-tests": "web-platform-tests",
  151. }
  152. compiled_suites = [
  153. "cppunit",
  154. "gtest",
  155. "jittest",
  156. ]
  157. common_suites = [
  158. "cppunit",
  159. "crashtest",
  160. "firefox-ui-functional",
  161. "gtest",
  162. "jittest",
  163. "jsreftest",
  164. "marionette",
  165. "marionette-e10s",
  166. "media-tests",
  167. "mochitests",
  168. "reftest",
  169. "web-platform-tests",
  170. "xpcshell",
  171. ]
  172. # Arguments we will accept on the command line and pass through to try
  173. # syntax with no further intervention. The set is taken from
  174. # http://trychooser.pub.build.mozilla.org with a few additions.
  175. #
  176. # Note that the meaning of store_false and store_true arguments is
  177. # not preserved here, as we're only using these to echo the literal
  178. # arguments to another consumer. Specifying either store_false or
  179. # store_true here will have an equivalent effect.
  180. pass_through_arguments = {
  181. '--rebuild': {
  182. 'action': 'store',
  183. 'dest': 'rebuild',
  184. 'help': 'Re-trigger all test jobs (up to 20 times)',
  185. },
  186. '--rebuild-talos': {
  187. 'action': 'store',
  188. 'dest': 'rebuild_talos',
  189. 'help': 'Re-trigger all talos jobs',
  190. },
  191. '--interactive': {
  192. 'action': 'store_true',
  193. 'dest': 'interactive',
  194. 'help': 'Allow ssh-like access to running test containers',
  195. },
  196. '--no-retry': {
  197. 'action': 'store_true',
  198. 'dest': 'no_retry',
  199. 'help': 'Do not retrigger failed tests',
  200. },
  201. '--setenv': {
  202. 'action': 'append',
  203. 'dest': 'setenv',
  204. 'help': 'Set the corresponding variable in the test environment for'
  205. 'applicable harnesses.',
  206. },
  207. '-f': {
  208. 'action': 'store_true',
  209. 'dest': 'failure_emails',
  210. 'help': 'Request failure emails only',
  211. },
  212. '--failure-emails': {
  213. 'action': 'store_true',
  214. 'dest': 'failure_emails',
  215. 'help': 'Request failure emails only',
  216. },
  217. '-e': {
  218. 'action': 'store_true',
  219. 'dest': 'all_emails',
  220. 'help': 'Request all emails',
  221. },
  222. '--all-emails': {
  223. 'action': 'store_true',
  224. 'dest': 'all_emails',
  225. 'help': 'Request all emails',
  226. },
  227. '--artifact': {
  228. 'action': 'store_true',
  229. 'dest': 'artifact',
  230. 'help': 'Force artifact builds where possible.',
  231. }
  232. }
  233. def __init__(self, topsrcdir, resolver_func, mach_context):
  234. self.topsrcdir = topsrcdir
  235. self._resolver_func = resolver_func
  236. self._resolver = None
  237. self.mach_context = mach_context
  238. if os.path.exists(os.path.join(self.topsrcdir, '.hg')):
  239. self._use_git = False
  240. else:
  241. self._use_git = True
  242. @property
  243. def resolver(self):
  244. if self._resolver is None:
  245. self._resolver = self._resolver_func()
  246. return self._resolver
  247. @property
  248. def config_path(self):
  249. return os.path.join(self.mach_context.state_dir, "autotry.ini")
  250. def load_config(self, name):
  251. config = ConfigParser.RawConfigParser()
  252. success = config.read([self.config_path])
  253. if not success:
  254. return None
  255. try:
  256. data = config.get("try", name)
  257. except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
  258. return None
  259. kwargs = vars(arg_parser().parse_args(self.split_try_string(data)))
  260. return kwargs
  261. def list_presets(self):
  262. config = ConfigParser.RawConfigParser()
  263. success = config.read([self.config_path])
  264. data = []
  265. if success:
  266. try:
  267. data = config.items("try")
  268. except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
  269. pass
  270. if not data:
  271. print("No presets found")
  272. for name, try_string in data:
  273. print("%s: %s" % (name, try_string))
  274. def split_try_string(self, data):
  275. return re.findall(r'(?:\[.*?\]|\S)+', data)
  276. def save_config(self, name, data):
  277. assert data.startswith("try: ")
  278. data = data[len("try: "):]
  279. parser = ConfigParser.RawConfigParser()
  280. parser.read([self.config_path])
  281. if not parser.has_section("try"):
  282. parser.add_section("try")
  283. parser.set("try", name, data)
  284. with open(self.config_path, "w") as f:
  285. parser.write(f)
  286. def paths_by_flavor(self, paths=None, tags=None):
  287. paths_by_flavor = defaultdict(set)
  288. if not (paths or tags):
  289. return dict(paths_by_flavor)
  290. tests = list(self.resolver.resolve_tests(paths=paths,
  291. tags=tags))
  292. for t in tests:
  293. if t['flavor'] in self.flavor_suites:
  294. flavor = t['flavor']
  295. if 'subsuite' in t and t['subsuite'] == 'devtools':
  296. flavor = 'devtools-chrome'
  297. if flavor in ['crashtest', 'reftest']:
  298. manifest_relpath = os.path.relpath(t['manifest'], self.topsrcdir)
  299. paths_by_flavor[flavor].add(os.path.dirname(manifest_relpath))
  300. elif 'dir_relpath' in t:
  301. paths_by_flavor[flavor].add(t['dir_relpath'])
  302. else:
  303. file_relpath = os.path.relpath(t['path'], self.topsrcdir)
  304. dir_relpath = os.path.dirname(file_relpath)
  305. paths_by_flavor[flavor].add(dir_relpath)
  306. for flavor, path_set in paths_by_flavor.items():
  307. paths_by_flavor[flavor] = self.deduplicate_prefixes(path_set, paths)
  308. return dict(paths_by_flavor)
  309. def deduplicate_prefixes(self, path_set, input_paths):
  310. # Removes paths redundant to test selection in the given path set.
  311. # If a path was passed on the commandline that is the prefix of a
  312. # path in our set, we only need to include the specified prefix to
  313. # run the intended tests (every test in "layout/base" will run if
  314. # "layout" is passed to the reftest harness).
  315. removals = set()
  316. additions = set()
  317. for path in path_set:
  318. full_path = path
  319. while path:
  320. path, _ = os.path.split(path)
  321. if path in input_paths:
  322. removals.add(full_path)
  323. additions.add(path)
  324. return additions | (path_set - removals)
  325. def remove_duplicates(self, paths_by_flavor, tests):
  326. rv = {}
  327. for item in paths_by_flavor:
  328. if self.flavor_suites[item] not in tests:
  329. rv[item] = paths_by_flavor[item].copy()
  330. return rv
  331. def calc_try_syntax(self, platforms, tests, talos, builds, paths_by_flavor, tags,
  332. extras, intersection):
  333. parts = ["try:", "-b", builds, "-p", ",".join(platforms)]
  334. suites = tests if not intersection else {}
  335. paths = set()
  336. for flavor, flavor_tests in paths_by_flavor.iteritems():
  337. suite = self.flavor_suites[flavor]
  338. if suite not in suites and (not intersection or suite in tests):
  339. for job_name in self.flavor_jobs[flavor]:
  340. for test in flavor_tests:
  341. paths.add("%s:%s" % (flavor, test))
  342. suites[job_name] = tests.get(suite, [])
  343. if not suites:
  344. raise ValueError("No tests found matching filters")
  345. if extras.get('artifact'):
  346. rejected = []
  347. for suite in suites.keys():
  348. if any([suite.startswith(c) for c in self.compiled_suites]):
  349. rejected.append(suite)
  350. if rejected:
  351. raise ValueError("You can't run {} with "
  352. "--artifact option.".format(', '.join(rejected)))
  353. parts.append("-u")
  354. parts.append(",".join("%s%s" % (k, "[%s]" % ",".join(v) if v else "")
  355. for k,v in sorted(suites.items())) if suites else "none")
  356. parts.append("-t")
  357. parts.append(",".join("%s%s" % (k, "[%s]" % ",".join(v) if v else "")
  358. for k,v in sorted(talos.items())) if talos else "none")
  359. if tags:
  360. parts.append(' '.join('--tag %s' % t for t in tags))
  361. if paths:
  362. parts.append("--try-test-paths %s" % " ".join(sorted(paths)))
  363. args_by_dest = {v['dest']: k for k, v in AutoTry.pass_through_arguments.items()}
  364. for dest, value in extras.iteritems():
  365. assert dest in args_by_dest
  366. arg = args_by_dest[dest]
  367. action = AutoTry.pass_through_arguments[arg]['action']
  368. if action == 'store':
  369. parts.append(arg)
  370. parts.append(value)
  371. if action == 'append':
  372. for e in value:
  373. parts.append(arg)
  374. parts.append(e)
  375. if action in ('store_true', 'store_false'):
  376. parts.append(arg)
  377. try_syntax = " ".join(parts)
  378. if extras.get('artifact') and 'all' in suites.keys():
  379. message = ('You asked for |-u all| with |--artifact| but compiled-code tests ({tests})'
  380. ' can\'t run against an artifact build. Try listing the suites you want'
  381. ' instead. For example, this syntax covers most suites:\n{try_syntax}')
  382. string_format = {
  383. 'tests': ','.join(self.compiled_suites),
  384. 'try_syntax': try_syntax.replace(
  385. '-u all',
  386. '-u ' + ','.join(sorted(set(self.common_suites) - set(self.compiled_suites)))
  387. )
  388. }
  389. raise ValueError(message.format(**string_format))
  390. return try_syntax
  391. def _run_git(self, *args):
  392. args = ['git'] + list(args)
  393. ret = subprocess.call(args)
  394. if ret:
  395. print('ERROR git command %s returned %s' %
  396. (args, ret))
  397. sys.exit(1)
  398. def _git_push_to_try(self, msg):
  399. self._run_git('commit', '--allow-empty', '-m', msg)
  400. try:
  401. self._run_git('push', 'hg::ssh://hg.mozilla.org/try',
  402. '+HEAD:refs/heads/branches/default/tip')
  403. finally:
  404. self._run_git('reset', 'HEAD~')
  405. def _git_find_changed_files(self):
  406. # This finds the files changed on the current branch based on the
  407. # diff of the current branch its merge-base base with other branches.
  408. try:
  409. args = ['git', 'rev-parse', 'HEAD']
  410. current_branch = subprocess.check_output(args).strip()
  411. args = ['git', 'for-each-ref', 'refs/heads', 'refs/remotes',
  412. '--format=%(objectname)']
  413. all_branches = subprocess.check_output(args).splitlines()
  414. other_branches = set(all_branches) - set([current_branch])
  415. args = ['git', 'merge-base', 'HEAD'] + list(other_branches)
  416. base_commit = subprocess.check_output(args).strip()
  417. args = ['git', 'diff', '--name-only', '-z', 'HEAD', base_commit]
  418. return subprocess.check_output(args).strip('\0').split('\0')
  419. except subprocess.CalledProcessError as e:
  420. print('Failed while determining files changed on this branch')
  421. print('Failed whle running: %s' % args)
  422. print(e.output)
  423. sys.exit(1)
  424. def _hg_find_changed_files(self):
  425. hg_args = [
  426. 'hg', 'log', '-r',
  427. '::. and not public()',
  428. '--template',
  429. '{join(files, "\n")}\n',
  430. ]
  431. try:
  432. return subprocess.check_output(hg_args).splitlines()
  433. except subprocess.CalledProcessError as e:
  434. print('Failed while finding files changed since the last '
  435. 'public ancestor')
  436. print('Failed whle running: %s' % hg_args)
  437. print(e.output)
  438. sys.exit(1)
  439. def find_changed_files(self):
  440. """Finds files changed in a local source tree.
  441. For hg, changes since the last public ancestor of '.' are
  442. considered. For git, changes in the current branch are considered.
  443. """
  444. if self._use_git:
  445. return self._git_find_changed_files()
  446. return self._hg_find_changed_files()
  447. def push_to_try(self, msg, verbose):
  448. if not self._use_git:
  449. try:
  450. hg_args = ['hg', 'push-to-try', '-m', msg]
  451. subprocess.check_call(hg_args, stderr=subprocess.STDOUT)
  452. except subprocess.CalledProcessError as e:
  453. print('ERROR hg command %s returned %s' % (hg_args, e.returncode))
  454. print('\nmach failed to push to try. There may be a problem '
  455. 'with your ssh key, or another issue with your mercurial '
  456. 'installation.')
  457. # Check for the presence of the "push-to-try" extension, and
  458. # provide instructions if it can't be found.
  459. try:
  460. subprocess.check_output(['hg', 'showconfig',
  461. 'extensions.push-to-try'])
  462. except subprocess.CalledProcessError:
  463. print('\nThe "push-to-try" hg extension is required. It '
  464. 'can be installed to Mercurial 3.3 or above by '
  465. 'running ./mach mercurial-setup')
  466. sys.exit(1)
  467. else:
  468. try:
  469. which.which('git-cinnabar')
  470. self._git_push_to_try(msg)
  471. except which.WhichError:
  472. print('ERROR git-cinnabar is required to push from git to try with'
  473. 'the autotry command.\n\nMore information can by found at '
  474. 'https://github.com/glandium/git-cinnabar')
  475. sys.exit(1)
  476. def find_uncommited_changes(self):
  477. if self._use_git:
  478. stat = subprocess.check_output(['git', 'status', '-z'])
  479. return any(len(entry.strip()) and entry.strip()[0] in ('A', 'M', 'D')
  480. for entry in stat.split('\0'))
  481. else:
  482. stat = subprocess.check_output(['hg', 'status'])
  483. return any(len(entry.strip()) and entry.strip()[0] in ('A', 'M', 'R')
  484. for entry in stat.splitlines())
  485. def find_paths_and_tags(self, verbose):
  486. paths, tags = set(), set()
  487. changed_files = self.find_changed_files()
  488. if changed_files:
  489. if verbose:
  490. print("Pushing tests based on modifications to the "
  491. "following files:\n\t%s" % "\n\t".join(changed_files))
  492. from mozbuild.frontend.reader import (
  493. BuildReader,
  494. EmptyConfig,
  495. )
  496. config = EmptyConfig(self.topsrcdir)
  497. reader = BuildReader(config)
  498. files_info = reader.files_info(changed_files)
  499. for path, info in files_info.items():
  500. paths |= info.test_files
  501. tags |= info.test_tags
  502. if verbose:
  503. if paths:
  504. print("Pushing tests based on the following patterns:\n\t%s" %
  505. "\n\t".join(paths))
  506. if tags:
  507. print("Pushing tests based on the following tags:\n\t%s" %
  508. "\n\t".join(tags))
  509. return paths, tags