blob: 9c6cf19399517df559d482e19a7f7ca1f647dcc3 [file] [log] [blame]
Kuang-che Wu6e4beca2018-06-27 17:45:02 +08001# -*- coding: utf-8 -*-
Kuang-che Wu88875db2017-07-20 10:47:53 +08002# Copyright 2017 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5"""Bisect command line interface."""
6
7from __future__ import print_function
8import argparse
9import datetime
Kuang-che Wu8b654092018-11-09 17:56:25 +080010import json
Kuang-che Wu88875db2017-07-20 10:47:53 +080011import logging
12import os
13import re
Kuang-che Wu443633f2019-02-27 00:58:33 +080014import signal
Kuang-che Wubcd32d52019-05-23 17:09:37 +080015import subprocess
16import sys
Kuang-che Wu88875db2017-07-20 10:47:53 +080017import textwrap
18import time
19
20from bisect_kit import common
Kuang-che Wu385279d2017-09-27 14:48:28 +080021from bisect_kit import configure
Kuang-che Wu88875db2017-07-20 10:47:53 +080022from bisect_kit import core
Kuang-che Wue121fae2018-11-09 16:18:39 +080023from bisect_kit import errors
Kuang-che Wu88875db2017-07-20 10:47:53 +080024from bisect_kit import strategy
25from bisect_kit import util
26
27logger = logging.getLogger(__name__)
28
Kuang-che Wu88875db2017-07-20 10:47:53 +080029DEFAULT_SESSION_NAME = 'default'
30DEFAULT_CONFIDENCE = 0.999
31
Kuang-che Wu0476d1f2019-03-04 19:27:01 +080032# Exit code of bisect eval script. These values are chosen compatible with 'git
33# bisect'.
34EXIT_CODE_OLD = 0
35EXIT_CODE_NEW = 1
36EXIT_CODE_SKIP = 125
37EXIT_CODE_FATAL = 128
38
Kuang-che Wu88875db2017-07-20 10:47:53 +080039
40class ArgTypeError(argparse.ArgumentTypeError):
41 """An error for argument validation failure.
42
43 This not only tells users the argument is wrong but also gives correct
44 example. The main purpose of this error is for argtype_multiplexer, which
45 cascades examples from multiple ArgTypeError.
46 """
47
48 def __init__(self, msg, example):
49 self.msg = msg
50 if isinstance(example, list):
51 self.example = example
52 else:
53 self.example = [example]
54 full_msg = '%s (example value: %s)' % (self.msg, ', '.join(self.example))
55 super(ArgTypeError, self).__init__(full_msg)
56
57
58def argtype_notempty(s):
59 """Validates argument is not an empty string.
60
61 Args:
62 s: string to validate.
63
64 Raises:
65 ArgTypeError if argument is empty string.
66 """
67 if not s:
68 msg = 'should not be empty'
69 raise ArgTypeError(msg, 'foo')
70 return s
71
72
73def argtype_int(s):
74 """Validate argument is a number.
75
76 Args:
77 s: string to validate.
78
79 Raises:
80 ArgTypeError if argument is not a number.
81 """
82 try:
83 return str(int(s))
84 except ValueError:
85 raise ArgTypeError('should be a number', '123')
86
87
Kuang-che Wu603cdad2019-01-18 21:32:55 +080088def argtype_re(pattern, example):
89 r"""Validate argument matches `pattern`.
90
91 Args:
92 pattern: regex pattern
93 example: example string which matches `pattern`
94
95 Returns:
96 A new argtype function which matches regex `pattern`
97 """
98 assert re.match(pattern, example)
99
100 def validate(s):
101 if re.match(pattern, s):
102 return s
103 if re.escape(pattern) == pattern:
104 raise ArgTypeError('should be "%s"' % pattern, pattern)
105 raise ArgTypeError('should match "%s"' % pattern,
106 '"%s" like %s' % (pattern, example))
107
108 return validate
109
110
Kuang-che Wu88875db2017-07-20 10:47:53 +0800111def argtype_multiplexer(*args):
112 r"""argtype multiplexer
113
Kuang-che Wu603cdad2019-01-18 21:32:55 +0800114 This function takes a list of argtypes and creates a new function matching
115 them. Moreover, it gives error message with examples.
Kuang-che Wu88875db2017-07-20 10:47:53 +0800116
Kuang-che Wubaaa4532018-08-15 17:08:10 +0800117 Examples:
Kuang-che Wu603cdad2019-01-18 21:32:55 +0800118 >>> argtype = argtype_multiplexer(argtype_int,
119 argtype_re(r'^r\d+$', 'r123'))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800120 >>> argtype('123')
121 123
122 >>> argtype('r456')
123 r456
124 >>> argtype('hello')
Kuang-che Wu603cdad2019-01-18 21:32:55 +0800125 ArgTypeError: Invalid argument (example value: 123, "r\d+$" like r123)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800126
127 Args:
128 *args: list of argtypes or regex pattern.
129
130 Returns:
131 A new argtype function which matches *args.
132 """
133
134 def validate(s):
135 examples = []
136 for t in args:
Kuang-che Wu88875db2017-07-20 10:47:53 +0800137 try:
138 return t(s)
139 except ArgTypeError as e:
140 examples += e.example
141
142 msg = 'Invalid argument'
143 raise ArgTypeError(msg, examples)
144
145 return validate
146
147
148def argtype_multiplier(argtype):
149 """A new argtype that supports multiplier suffix of the given argtype.
150
Kuang-che Wubaaa4532018-08-15 17:08:10 +0800151 Examples:
Kuang-che Wu88875db2017-07-20 10:47:53 +0800152 Supports the given argtype accepting "foo" as argument, this function
153 generates a new argtype function which accepts argument like "foo*3".
154
155 Returns:
156 A new argtype function which returns (arg, times) where arg is accepted
157 by input `argtype` and times is repeating count. Note that if multiplier is
158 omitted, "times" is 1.
159 """
160
161 def helper(s):
162 m = re.match(r'^(.+)\*(\d+)$', s)
163 try:
164 if m:
165 return argtype(m.group(1)), int(m.group(2))
Kuang-che Wu68db08a2018-03-30 11:50:34 +0800166 return argtype(s), 1
Kuang-che Wu88875db2017-07-20 10:47:53 +0800167 except ArgTypeError as e:
168 # It should be okay to gives multiplier example only for the first one
169 # because it is just "example", no need to enumerate all possibilities.
170 raise ArgTypeError(e.msg, e.example + [e.example[0] + '*3'])
171
172 return helper
173
174
175def argtype_dir_path(s):
176 """Validate argument is an existing directory.
177
178 Args:
179 s: string to validate.
180
181 Raises:
182 ArgTypeError if the path is not a directory.
183 """
184 if not os.path.exists(s):
185 raise ArgTypeError('should be an existing directory', '/path/to/somewhere')
186 if not os.path.isdir(s):
187 raise ArgTypeError('should be a directory', '/path/to/somewhere')
188
189 # Normalize, trim trailing path separators.
190 if len(s) > 1 and s[-1] == os.path.sep:
191 s = s[:-1]
192 return s
193
194
195def _collect_bisect_result_values(values, line):
196 """Collect bisect result values from output line.
197
198 Args:
199 values: Collected values are appending to this list.
200 line: One line of output string.
201 """
202 m = re.match(r'^BISECT_RESULT_VALUES=(.+)', line)
203 if m:
204 try:
205 values.extend(map(float, m.group(1).split()))
206 except ValueError:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800207 raise errors.InternalError(
Kuang-che Wu88875db2017-07-20 10:47:53 +0800208 'BISECT_RESULT_VALUES should be list of floats: %r' % m.group(1))
209
210
Kuang-che Wu88518882017-09-22 16:57:25 +0800211def check_executable(program):
212 """Checks whether a program is executable.
213
214 Args:
215 program: program path in question
216
217 Returns:
218 string as error message if `program` is not executable, or None otherwise.
219 It will return None if unable to determine as well.
220 """
221 returncode = util.call('which', program)
222 if returncode == 127: # No 'which' on this platform, skip the check.
223 return None
224 if returncode == 0: # is executable
225 return None
226
227 hint = ''
228 if not os.path.exists(program):
229 hint = 'Not in PATH?'
230 elif not os.path.isfile(program):
231 hint = 'Not a file'
232 elif not os.access(program, os.X_OK):
233 hint = 'Forgot to chmod +x?'
234 elif '/' not in program:
235 hint = 'Forgot to prepend "./" ?'
236 return '%r is not executable. %s' % (program, hint)
237
238
Kuang-che Wubc7bfce2019-05-21 18:43:16 +0800239def lookup_signal_name(signum):
240 """Look up signal name by signal number.
241
242 Args:
243 signum: signal number
244
245 Returns:
246 signal name, like "SIGTERM". "Unknown" for unexpected number.
247 """
248 for k, v in vars(signal).items():
249 if k.startswith('SIG') and '_' not in k and v == signum:
250 return k
251 return 'Unknown'
252
253
Kuang-che Wu443633f2019-02-27 00:58:33 +0800254def format_returncode(returncode):
Kuang-che Wubc7bfce2019-05-21 18:43:16 +0800255 # returncode is negative if the process is terminated by signal directly.
Kuang-che Wu443633f2019-02-27 00:58:33 +0800256 if returncode < 0:
257 signum = -returncode
Kuang-che Wubc7bfce2019-05-21 18:43:16 +0800258 signame = lookup_signal_name(signum)
Kuang-che Wu443633f2019-02-27 00:58:33 +0800259 return 'terminated by signal %d (%s)' % (signum, signame)
Kuang-che Wubc7bfce2019-05-21 18:43:16 +0800260 # Some programs, e.g. shell, handled signal X and exited with (128 + X).
261 if returncode > 128:
262 signum = returncode - 128
263 signame = lookup_signal_name(signum)
264 return 'exited with code %d; may be signal %d (%s)' % (returncode, signum,
265 signame)
Kuang-che Wu443633f2019-02-27 00:58:33 +0800266
267 return 'exited with code %d' % returncode
268
269
Kuang-che Wubcd32d52019-05-23 17:09:37 +0800270def _execute_command(step, args, env=None, stdout_callback=None):
271 """Helper of do_evaluate() and do_switch().
272
273 Args:
274 step: step name
275 args: command line arguments
276 env: environment variables
277 stdout_callback: Callback function for stdout. Called once per line.
278
279 Returns:
280 returncode; range 0 <= returncode < 128
281
282 Raises:
283 errors.ExecutionFatalError if child process returned fatal error code.
284 """
285 stderr_lines = []
286 p = util.Popen(
287 args,
288 env=env,
289 stdout_callback=stdout_callback,
290 stderr_callback=stderr_lines.append)
291 returncode = p.wait()
292 if returncode < 0 or returncode >= 128:
293 # Only output error messages of child process if it is fatal error.
294 print(
295 'Last stderr lines of "%s"' % subprocess.list2cmdline(args),
296 file=sys.stderr)
297 print('=' * 40, file=sys.stderr)
298 for line in stderr_lines[-50:]:
299 print(line, end='', file=sys.stderr)
300 print('=' * 40, file=sys.stderr)
301 raise errors.ExecutionFatalError(
302 '%s failed: %s' % (step, format_returncode(returncode)))
303 return returncode
304
305
Kuang-che Wu88875db2017-07-20 10:47:53 +0800306def do_evaluate(evaluate_cmd, domain, rev):
307 """Invokes evaluator command.
308
309 The `evaluate_cmd` can get the target revision from the environment variable
310 named 'BISECT_REV'.
311
312 The result is determined according to the exit code of evaluator:
313 0: 'old'
Kuang-che Wu0476d1f2019-03-04 19:27:01 +0800314 1..124, 126, 127: 'new'
Kuang-che Wu88875db2017-07-20 10:47:53 +0800315 125: 'skip'
Kuang-che Wu0476d1f2019-03-04 19:27:01 +0800316 128..255: fatal error
Kuang-che Wu88875db2017-07-20 10:47:53 +0800317 terminated by signal: fatal error
318
319 p.s. the definition of result is compatible with git-bisect(1).
320
321 It also extracts additional values from evaluate_cmd's stdout lines which
322 match the following format:
323 BISECT_RESULT_VALUES=<float>[, <float>]*
324
325 Args:
326 evaluate_cmd: evaluator command.
327 domain: a bisect_kit.core.Domain instance.
328 rev: version to evaluate.
329
330 Returns:
331 (result, values):
332 result is one of 'old', 'new', 'skip'.
333 values are additional collected values, like performance score.
334
335 Raises:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800336 errors.ExecutionFatalError if evaluator returned fatal error code.
Kuang-che Wu88875db2017-07-20 10:47:53 +0800337 """
338 env = os.environ.copy()
339 env['BISECT_REV'] = rev
340 domain.setenv(env, rev)
341
342 values = []
Kuang-che Wubcd32d52019-05-23 17:09:37 +0800343 returncode = _execute_command(
344 'eval',
Kuang-che Wu88875db2017-07-20 10:47:53 +0800345 evaluate_cmd,
346 env=env,
347 stdout_callback=lambda line: _collect_bisect_result_values(values, line))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800348 if returncode == 0:
349 return 'old', values
350 if returncode == 125:
351 return 'skip', values
352 return 'new', values
353
354
355def do_switch(switch_cmd, domain, rev):
356 """Invokes switcher command.
357
358 The `switch_cmd` can get the target revision from the environment variable
359 named 'BISECT_REV'.
360
361 The result is determined according to the exit code of switcher:
362 0: switch succeeded
Kuang-che Wu0476d1f2019-03-04 19:27:01 +0800363 1..127: 'skip'
364 128..255: fatal error
Kuang-che Wu88875db2017-07-20 10:47:53 +0800365 terminated by signal: fatal error
366
367 In other words, any non-fatal errors are considered as 'skip'.
368
369 Args:
370 switch_cmd: switcher command.
371 domain: a bisect_kit.core.Domain instance.
372 rev: version to switch.
373
374 Returns:
375 None if switch successfully, 'skip' otherwise.
376
377 Raises:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800378 errors.ExecutionFatalError if switcher returned fatal error code.
Kuang-che Wu88875db2017-07-20 10:47:53 +0800379 """
380 env = os.environ.copy()
381 env['BISECT_REV'] = rev
382 domain.setenv(env, rev)
383
Kuang-che Wubcd32d52019-05-23 17:09:37 +0800384 returncode = _execute_command('switch', switch_cmd, env=env)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800385 if returncode != 0:
386 return 'skip'
387 return None
388
389
Kuang-che Wu68db08a2018-03-30 11:50:34 +0800390class BisectorCommandLine(object):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800391 """Bisector command line interface.
392
393 The typical usage pattern:
394
395 if __name__ == '__main__':
Kuang-che Wu68db08a2018-03-30 11:50:34 +0800396 BisectorCommandLine(CustomDomain).main()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800397
398 where CustomDomain is a derived class of core.BisectDomain. See
Kuang-che Wu02170592018-07-09 21:42:44 +0800399 bisect_list.py as example.
Kuang-che Wu88875db2017-07-20 10:47:53 +0800400
401 If you need to control the bisector using python code, the easier way is
402 passing command line arguments to main() function. For example,
403 bisector = Bisector(CustomDomain)
404 bisector.main('init', '--old', '123', '--new', '456')
405 bisector.main('config', 'switch', 'true')
406 bisector.main('config', 'eval', 'true')
407 bisector.main('run')
408 """
409
410 def __init__(self, domain_cls):
411 self.domain_cls = domain_cls
412 self.domain = None
413 self.states = None
414 self.strategy = None
415
416 @property
417 def config(self):
418 return self.states.config
419
Kuang-che Wu1dc5bd72019-01-19 00:14:46 +0800420 def _format_status(self, status):
421 if status in ('old', 'new'):
Kuang-che Wub6756d42019-01-25 12:19:55 +0800422 return '%s behavior' % status
Kuang-che Wu1dc5bd72019-01-19 00:14:46 +0800423 return status
424
Kuang-che Wu88875db2017-07-20 10:47:53 +0800425 def cmd_reset(self, _opts):
426 """Resets bisect session and clean up saved result."""
427 self.states.reset()
428
429 def cmd_init(self, opts):
430 """Initializes bisect session.
431
432 See init command's help message for more detail.
433 """
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800434 if (opts.old_value is None) != (opts.new_value is None):
435 raise errors.ArgumentError('--old_value and --new_value',
436 'both should be specified')
437 if opts.old_value is not None and opts.old_value == opts.new_value:
438 raise errors.ArgumentError('--old_value and --new_value',
439 'their values should be different')
440 if opts.recompute_init_values and opts.old_value is None:
441 raise errors.ArgumentError(
442 '--recompute_init_values',
443 '--old_value and --new_value must be specified '
444 'when --recompute_init_values is present')
445
Kuang-che Wu88875db2017-07-20 10:47:53 +0800446 config, revlist = self.domain_cls.init(opts)
Kuang-che Wu42551dd2018-01-16 17:27:20 +0800447 logger.info('found %d revs to bisect', len(revlist))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800448 logger.debug('revlist %r', revlist)
449 if 'new' not in config:
450 config['new'] = opts.new
451 if 'old' not in config:
452 config['old'] = opts.old
453 assert len(revlist) >= 2
454 assert config['new'] in revlist
455 assert config['old'] in revlist
456 old_idx = revlist.index(config['old'])
457 new_idx = revlist.index(config['new'])
458 assert old_idx < new_idx
459
Kuang-che Wu81cde452019-04-08 16:56:51 +0800460 config.update(
461 confidence=opts.confidence,
462 noisy=opts.noisy,
463 old_value=opts.old_value,
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800464 new_value=opts.new_value,
465 recompute_init_values=opts.recompute_init_values)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800466
467 self.states.init(config, revlist)
468 self.states.save()
469
470 def _switch_and_eval(self, rev, prev_rev=None):
471 """Switches and evaluates given version.
472
473 If current version equals to target, switch step will be skip.
474
475 Args:
476 rev: Target version.
477 prev_rev: Previous version.
478
479 Returns:
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800480 (step, sample):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800481 step: Last step executed ('switch' or 'eval').
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800482 sample (dict): sampling result of `rev`. The dict contains:
483 status: Execution result ('old', 'new', or 'skip').
484 values: For eval bisection, collected values from eval step.
485 switch_time: how much time in switch step
486 eval_time: how much time in eval step
Kuang-che Wu88875db2017-07-20 10:47:53 +0800487 """
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800488 sample = {'rev': rev}
Kuang-che Wu88875db2017-07-20 10:47:53 +0800489 if prev_rev != rev:
490 logger.debug('switch to rev=%s', rev)
491 t0 = time.time()
492 status = do_switch(self.config['switch'], self.domain, rev)
493 t1 = time.time()
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800494 sample['switch_time'] = t1 - t0
495 sample['status'] = status
Kuang-che Wu88875db2017-07-20 10:47:53 +0800496 if status == 'skip':
497 logger.debug('switch failed => skip')
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800498 return 'switch', sample
Kuang-che Wu88875db2017-07-20 10:47:53 +0800499
500 logger.debug('eval rev=%s', rev)
501 t0 = time.time()
502 status, values = do_evaluate(self.config['eval'], self.domain, rev)
503 t1 = time.time()
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800504 sample['eval_time'] = t1 - t0
505 sample['status'] = status
Kuang-che Wu88875db2017-07-20 10:47:53 +0800506 if status == 'skip':
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800507 return 'eval', sample
Kuang-che Wu81cde452019-04-08 16:56:51 +0800508
509 if self.strategy.is_value_bisection():
Kuang-che Wuc986b1d2019-04-15 16:45:20 +0800510 if not values:
511 raise errors.ExecutionFatalError(
512 'eval command (%s) terminated normally but did not output values' %
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800513 self.config['eval'])
514 sample['values'] = values
515 sample['status'] = self.strategy.classify_result_from_values(values)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800516
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800517 return 'eval', sample
Kuang-che Wu88875db2017-07-20 10:47:53 +0800518
Kuang-che Wu889f68e2018-10-29 14:12:13 +0800519 def _next_idx_iter(self, opts, force):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800520 if opts.revs:
521 for rev in opts.revs:
522 idx = self.states.rev2idx(rev)
523 logger.info('try idx=%d rev=%s (command line specified)', idx, rev)
524 yield idx, rev
525 if opts.once:
526 break
527 else:
Kuang-che Wu889f68e2018-10-29 14:12:13 +0800528 while force or not self.strategy.is_done():
Kuang-che Wu88875db2017-07-20 10:47:53 +0800529 idx = self.strategy.next_idx()
530 rev = self.states.idx2rev(idx)
531 logger.info('try idx=%d rev=%s', idx, rev)
532 yield idx, rev
Kuang-che Wu889f68e2018-10-29 14:12:13 +0800533 force = False
Kuang-che Wu88875db2017-07-20 10:47:53 +0800534 if opts.once:
535 break
536
537 def cmd_run(self, opts):
538 """Performs bisection.
539
540 See run command's help message for more detail.
541
542 Raises:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800543 errors.VerificationFailed: The bisection range is verified false. We
Kuang-che Wu88875db2017-07-20 10:47:53 +0800544 expect 'old' at the first rev and 'new' at last rev.
Kuang-che Wue121fae2018-11-09 16:18:39 +0800545 errors.UnableToProceed: Too many errors to narrow down further the
546 bisection range.
Kuang-che Wu88875db2017-07-20 10:47:53 +0800547 """
Kuang-che Wu8b654092018-11-09 17:56:25 +0800548 # Set dummy values in case exception raised before loop.
549 idx, rev = -1, None
550 try:
551 assert self.config.get('switch')
552 assert self.config.get('eval')
Kuang-che Wu88875db2017-07-20 10:47:53 +0800553
Kuang-che Wu8b654092018-11-09 17:56:25 +0800554 prev_rev = None
555 force = opts.force
556 for idx, rev in self._next_idx_iter(opts, force):
557 if not force:
558 # Bail out if bisection range is unlikely true in order to prevent
559 # wasting time. This is necessary because some configurations (say,
560 # confidence) may be changed before cmd_run() and thus the bisection
561 # range becomes not acceptable.
562 self.strategy.check_verification_range()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800563
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800564 step, sample = self._switch_and_eval(rev, prev_rev=prev_rev)
565 self.states.add_history('sample', **sample)
Kuang-che Wu8b654092018-11-09 17:56:25 +0800566 self.states.save()
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800567 if 'values' in sample:
568 logger.info('rev=%s status => %s: %s', rev,
569 self._format_status(sample['status']), sample['values'])
570 else:
571 logger.info('rev=%s status => %s', rev,
572 self._format_status(sample['status']))
573 force = False
Kuang-che Wu8b654092018-11-09 17:56:25 +0800574
575 # Bail out if bisection range is unlikely true.
Kuang-che Wu889f68e2018-10-29 14:12:13 +0800576 self.strategy.check_verification_range()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800577
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800578 self.strategy.add_sample(idx, **sample)
Kuang-che Wu8b654092018-11-09 17:56:25 +0800579 self.strategy.show_summary()
580
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800581 if step == 'switch' and sample['status'] == 'skip':
Kuang-che Wu8b654092018-11-09 17:56:25 +0800582 # Previous switch failed and thus the current version is unknown. Set
583 # it None, so next switch operation won't be bypassed (due to
584 # optimization).
585 prev_rev = None
586 else:
587 prev_rev = rev
588
589 logger.info('done')
590 old_idx, new_idx = self.strategy.get_range()
591 self.states.add_history('done')
Kuang-che Wu889f68e2018-10-29 14:12:13 +0800592 self.states.save()
Kuang-che Wu8b654092018-11-09 17:56:25 +0800593 except Exception as e:
594 exception_name = e.__class__.__name__
595 self.states.add_history(
596 'failed', text='%s: %s' % (exception_name, e), index=idx, rev=rev)
597 self.states.save()
598 raise
599 finally:
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800600 if rev and self.strategy.state == self.strategy.STARTED:
Kuang-che Wu8b654092018-11-09 17:56:25 +0800601 # progress so far
602 old_idx, new_idx = self.strategy.get_range()
603 self.states.add_history(
604 'range',
605 old=self.states.idx2rev(old_idx),
606 new=self.states.idx2rev(new_idx))
607 self.states.save()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800608
609 def cmd_view(self, opts):
Kuang-che Wue80bb872018-11-15 19:45:25 +0800610 """Shows remaining candidates."""
Kuang-che Wue80bb872018-11-15 19:45:25 +0800611 old_idx, new_idx = self.strategy.get_range()
612 old, new = map(self.states.idx2rev, [old_idx, new_idx])
613 highlight_old_idx, highlight_new_idx = self.strategy.get_range(
614 self.strategy.confidence / 10.0)
615 summary = {
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800616 'rev_info': [info.to_dict() for info in self.strategy.rev_info],
Kuang-che Wue80bb872018-11-15 19:45:25 +0800617 'current_range': (old, new),
618 'highlight_range':
619 map(self.states.idx2rev, [highlight_old_idx, highlight_new_idx]),
620 'prob':
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800621 self.strategy.get_prob(),
Kuang-che Wue80bb872018-11-15 19:45:25 +0800622 'remaining_steps':
623 self.strategy.remaining_steps(),
624 }
625
626 if opts.verbose or opts.json:
627 interesting_indexes = set(range(len(summary['rev_info'])))
628 else:
629 interesting_indexes = set([old_idx, new_idx])
Kuang-che Wu15874b62019-01-11 21:10:27 +0800630 if self.strategy.prob:
631 for i, p in enumerate(self.strategy.prob):
632 if p > 0.05:
633 interesting_indexes.add(i)
Kuang-che Wue80bb872018-11-15 19:45:25 +0800634
635 self.domain.fill_candidate_summary(summary, interesting_indexes)
636
637 if opts.json:
638 print(json.dumps(summary, indent=2, sort_keys=True))
639 else:
640 self.show_summary(summary, interesting_indexes, verbose=opts.verbose)
641
642 def show_summary(self, summary, interesting_indexes, verbose=False):
643 old, new = summary['current_range']
644 old_idx, new_idx = map(self.states.data['revlist'].index, [old, new])
645
Kuang-che Wuaccf9202019-01-04 15:40:42 +0800646 for link in summary.get('links', []):
647 print('%s: %s' % (link['name'], link['url']))
648 if 'note' in link:
649 print(link['note'])
Kuang-che Wue80bb872018-11-15 19:45:25 +0800650
651 print('Range: (%s, %s], %s revs left' % (old, new, (new_idx - old_idx)))
Kuang-che Wua8c987f2019-01-18 14:26:43 +0800652 if summary.get('remaining_steps'):
Kuang-che Wue80bb872018-11-15 19:45:25 +0800653 print('(roughly %d steps)' % summary['remaining_steps'])
654
655 for i, rev_info in enumerate(summary['rev_info']):
656 if (not verbose and not old_idx <= i <= new_idx and
657 not rev_info['result_counter']):
658 continue
659
660 detail = []
Kuang-che Wu05e416e2019-02-21 12:33:52 +0800661 if self.strategy.is_noisy() and summary['prob']:
Kuang-che Wua8c987f2019-01-18 14:26:43 +0800662 detail.append('%.4f%%' % (summary['prob'][i] * 100))
Kuang-che Wue80bb872018-11-15 19:45:25 +0800663 if rev_info['result_counter']:
664 detail.append(str(rev_info['result_counter']))
665 values = sorted(rev_info['values'])
666 if len(values) == 1:
667 detail.append('%.3f' % values[0])
668 elif len(values) > 1:
669 detail.append('n=%d,avg=%.3f,median=%.3f,min=%.3f,max=%.3f' %
670 (len(values), sum(values) / len(values),
671 values[len(values) // 2], values[0], values[-1]))
672
673 print('[%d] %s\t%s' % (i, rev_info['rev'], ' '.join(detail)))
674 if i in interesting_indexes:
675 if 'comment' in rev_info:
676 print('\t%s' % rev_info['comment'])
677 for action in rev_info.get('actions', []):
678 if 'text' in action:
679 print('\t%s' % action['text'])
680 if 'link' in action:
681 print('\t%s' % action['link'])
Kuang-che Wu88875db2017-07-20 10:47:53 +0800682
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800683 def _strategy_factory(self):
684 rev_info = self.states.load_rev_info()
685 assert rev_info
686 return strategy.NoisyBinarySearch(
687 rev_info,
688 self.states.rev2idx(self.config['old']),
689 self.states.rev2idx(self.config['new']),
690 old_value=self.config['old_value'],
691 new_value=self.config['new_value'],
692 recompute_init_values=self.config['recompute_init_values'],
693 confidence=self.config['confidence'],
694 observation=self.config['noisy'])
695
Kuang-che Wu88875db2017-07-20 10:47:53 +0800696 def current_status(self, session=None, session_base=None):
697 """Gets current bisect status.
698
699 Returns:
700 A dict describing current status. It contains following items:
701 inited: True iff the session file is initialized (init command has been
702 invoked). If not, below items are omitted.
703 old: Start of current estimated range.
704 new: End of current estimated range.
Kuang-che Wu8b654092018-11-09 17:56:25 +0800705 verified: The bisect range is already verified.
Kuang-che Wu88875db2017-07-20 10:47:53 +0800706 estimated_noise: New estimated noise.
707 done: True if bisection is done, otherwise False.
708 """
709 self._create_states(session=session, session_base=session_base)
710 if self.states.load():
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800711 self.strategy = self._strategy_factory()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800712 left, right = self.strategy.get_range()
713 estimated_noise = self.strategy.get_noise_observation()
714
715 result = dict(
716 inited=True,
717 old=self.states.idx2rev(left),
718 new=self.states.idx2rev(right),
Kuang-che Wu8b654092018-11-09 17:56:25 +0800719 verified=self.strategy.is_range_verified(),
Kuang-che Wudd7f6f02018-06-28 18:19:30 +0800720 estimated_noise=estimated_noise,
Kuang-che Wu88875db2017-07-20 10:47:53 +0800721 done=self.strategy.is_done())
722 else:
723 result = dict(inited=False)
724 return result
725
Kuang-che Wu8b654092018-11-09 17:56:25 +0800726 def cmd_log(self, opts):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800727 """Prints what has been done so far."""
Kuang-che Wu8b654092018-11-09 17:56:25 +0800728 history = []
Kuang-che Wu88875db2017-07-20 10:47:53 +0800729 for entry in self.states.data['history']:
Kuang-che Wu8b654092018-11-09 17:56:25 +0800730 if opts.before and entry['timestamp'] >= opts.before:
731 continue
732 if opts.after and entry['timestamp'] <= opts.after:
733 continue
734 history.append(entry)
735
736 if opts.json:
737 print(json.dumps(history, indent=2))
738 return
739
740 for entry in history:
741 entry_time = datetime.datetime.fromtimestamp(int(entry['timestamp']))
742 if entry.get('event', 'sample') == 'sample':
743 print('{datetime} {rev} {status} {values} {comment}'.format(
744 datetime=entry_time,
745 rev=entry['rev'],
746 status=entry['status'] + ('*%d' % entry['times']
747 if entry.get('times', 1) > 1 else ''),
748 values=entry.get('values', ''),
749 comment=entry.get('comment', '')))
750 else:
751 print('%s %r' % (entry_time, entry))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800752
753 def cmd_next(self, _opts):
754 """Prints next suggested rev to bisect."""
Kuang-che Wu88875db2017-07-20 10:47:53 +0800755 if self.strategy.is_done():
756 print('done')
757 return
758
759 idx = self.strategy.next_idx()
760 rev = self.states.idx2rev(idx)
761 print(rev)
762
763 def cmd_switch(self, opts):
764 """Switches to given rev without eval."""
765 assert self.config.get('switch')
766
Kuang-che Wu88875db2017-07-20 10:47:53 +0800767 if opts.rev == 'next':
768 idx = self.strategy.next_idx()
769 rev = self.states.idx2rev(idx)
770 else:
Kuang-che Wu752228c2018-09-05 13:54:22 +0800771 rev = self.domain_cls.intra_revtype(opts.rev)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800772 assert rev
773
774 logger.info('switch to %s', rev)
775 status = do_switch(self.config['switch'], self.domain, rev)
776 if status:
777 print('switch failed')
778
779 def _add_revs_status_helper(self, revs, status):
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800780 if self.strategy.is_value_bisection():
781 assert status not in ('old', 'new')
Kuang-che Wu88875db2017-07-20 10:47:53 +0800782 for rev, times in revs:
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800783 idx = self.states.rev2idx(rev)
784 sample = {'rev': rev, 'status': status}
785 # times=1 is default in the loader. Add 'times' entry only if necessary
786 # in order to simplify the dict.
787 if times > 1:
788 sample['times'] = times
789 self.states.add_history('sample', **sample)
790 self.states.save()
791 self.strategy.add_sample(idx, **sample)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800792
793 def cmd_new(self, opts):
794 """Tells bisect engine the said revs have "new" behavior."""
795 logger.info('set [%s] as new', opts.revs)
796 self._add_revs_status_helper(opts.revs, 'new')
797
798 def cmd_old(self, opts):
799 """Tells bisect engine the said revs have "old" behavior."""
800 logger.info('set [%s] as old', opts.revs)
801 self._add_revs_status_helper(opts.revs, 'old')
802
803 def cmd_skip(self, opts):
804 """Tells bisect engine the said revs have "skip" behavior."""
805 logger.info('set [%s] as skip', opts.revs)
806 self._add_revs_status_helper(opts.revs, 'skip')
807
808 def _create_states(self, session=None, session_base=None):
809 if not session:
810 session = DEFAULT_SESSION_NAME
811 if not session_base:
Kuang-che Wu41e8b592018-09-25 17:01:30 +0800812 session_base = configure.get('SESSION_BASE', common.DEFAULT_SESSION_BASE)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800813
814 session_file = os.path.join(session_base, session, self.domain_cls.__name__)
815
816 if self.states:
817 assert self.states.session_file == session_file
818 else:
Kuang-che Wuc5781932018-10-05 00:30:19 +0800819 self.states = core.BisectStates(session_file)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800820
821 def cmd_config(self, opts):
822 """Configures additional setting.
823
824 See config command's help message for more detail.
825 """
826 self.states.load()
827 self.domain = self.domain_cls(self.states.config)
828 if not opts.value:
829 print(self.states.config[opts.key])
830 return
831
832 if opts.key in ['switch', 'eval']:
Kuang-che Wu88518882017-09-22 16:57:25 +0800833 result = check_executable(opts.value[0])
834 if result:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800835 raise errors.ArgumentError('%s command' % opts.key, result)
Kuang-che Wu88518882017-09-22 16:57:25 +0800836
Kuang-che Wu88875db2017-07-20 10:47:53 +0800837 self.states.config[opts.key] = opts.value
838
839 elif opts.key == 'confidence':
840 if len(opts.value) != 1:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800841 raise errors.ArgumentError(
842 'confidence value',
843 'expected 1 value, %d values given' % len(opts.value))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800844 try:
845 self.states.config[opts.key] = float(opts.value[0])
846 except ValueError:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800847 raise errors.ArgumentError('confidence value',
848 'invalid float value: %r' % opts.value[0])
Kuang-che Wu88875db2017-07-20 10:47:53 +0800849
850 elif opts.key == 'noisy':
851 if len(opts.value) != 1:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800852 raise errors.ArgumentError(
853 'noisy value',
854 'expected 1 value, %d values given' % len(opts.value))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800855 self.states.config[opts.key] = opts.value[0]
856
857 else:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800858 # unreachable
859 assert 0
Kuang-che Wu88875db2017-07-20 10:47:53 +0800860
861 self.states.save()
862
863 def create_argument_parser(self, prog):
Kuang-che Wub2376262017-11-20 18:05:24 +0800864 if self.domain_cls.help:
865 description = self.domain_cls.help
866 else:
867 description = 'Bisector for %s' % self.domain_cls.__name__
868 description += textwrap.dedent('''
869 When running switcher and evaluator, it will set BISECT_REV environment
870 variable, indicates current rev to switch/evaluate.
871 ''')
872
Kuang-che Wu88875db2017-07-20 10:47:53 +0800873 parser = argparse.ArgumentParser(
874 prog=prog,
875 formatter_class=argparse.RawDescriptionHelpFormatter,
Kuang-che Wub2376262017-11-20 18:05:24 +0800876 description=description)
Kuang-che Wu385279d2017-09-27 14:48:28 +0800877 common.add_common_arguments(parser)
878 parser.add_argument(
Kuang-che Wu88875db2017-07-20 10:47:53 +0800879 '--session_base',
Kuang-che Wu41e8b592018-09-25 17:01:30 +0800880 default=configure.get('SESSION_BASE', common.DEFAULT_SESSION_BASE),
Kuang-che Wu88875db2017-07-20 10:47:53 +0800881 help='Directory to store sessions (default: %(default)r)')
882 parser.add_argument(
883 '--session',
884 default=DEFAULT_SESSION_NAME,
885 help='Session name (default: %(default)r)')
886 subparsers = parser.add_subparsers(
887 dest='command', title='commands', metavar='<command>')
888
889 parser_reset = subparsers.add_parser(
890 'reset', help='Reset bisect session and clean up saved result')
891 parser_reset.set_defaults(func=self.cmd_reset)
892
893 parser_init = subparsers.add_parser(
894 'init',
895 help='Initializes bisect session',
896 formatter_class=argparse.RawDescriptionHelpFormatter,
897 description=textwrap.dedent('''
898 Besides arguments for 'init' command, you also need to set 'switch'
899 and 'eval' command line via 'config' command.
900 $ bisector config switch <switch command and arguments>
901 $ bisector config eval <eval command and arguments>
902
903 The value of --noisy and --confidence could be changed by 'config'
904 command after 'init' as well.
905 '''))
906 parser_init.add_argument(
907 '--old',
908 required=True,
909 type=self.domain_cls.revtype,
910 help='Start of bisect range, which has old behavior')
911 parser_init.add_argument(
912 '--new',
913 required=True,
914 type=self.domain_cls.revtype,
915 help='End of bisect range, which has new behavior')
916 parser_init.add_argument(
917 '--noisy',
918 help='Enable noisy binary search and specify prior result. '
919 'For example, "old=1/10,new=2/3" means old fail rate is 1/10 '
920 'and new fail rate increased to 2/3. '
921 'Skip if not flaky, say, "new=2/3" means old is always good.')
922 parser_init.add_argument(
Kuang-che Wu81cde452019-04-08 16:56:51 +0800923 '--old_value',
924 type=float,
925 help='For performance test, value of old behavior')
926 parser_init.add_argument(
927 '--new_value',
928 type=float,
929 help='For performance test, value of new behavior')
930 parser_init.add_argument(
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800931 '--recompute_init_values',
932 action='store_true',
933 help='For performance test, recompute initial values')
934 parser_init.add_argument(
Kuang-che Wu88875db2017-07-20 10:47:53 +0800935 '--confidence',
936 type=float,
937 default=DEFAULT_CONFIDENCE,
938 help='Confidence level (default: %(default)r)')
939 parser_init.set_defaults(func=self.cmd_init)
940 self.domain_cls.add_init_arguments(parser_init)
941
942 parser_config = subparsers.add_parser(
943 'config', help='Configures additional setting')
944 parser_config.add_argument(
945 'key',
946 choices=['switch', 'eval', 'confidence', 'noisy'],
947 metavar='key',
948 help='What config to change. choices=[%(choices)s]')
949 parser_config.add_argument(
950 'value', nargs=argparse.REMAINDER, help='New value')
951 parser_config.set_defaults(func=self.cmd_config)
952
953 parser_run = subparsers.add_parser(
954 'run',
955 help='Performs bisection',
956 formatter_class=argparse.RawDescriptionHelpFormatter,
957 description=textwrap.dedent('''
958 This command does switch and eval to determine candidates having old or
959 new behavior.
960
961 By default, it attempts to try versions in binary search manner until
962 found the first version having new behavior.
963
964 If version numbers are specified on command line, it just tries those
965 versions and record the result.
966
967 Example:
968 Bisect automatically.
969 $ %(prog)s
970
971 Switch and run version "2.13" and "2.14" and then stop.
972 $ %(prog)s 2.13 2.14
973 '''))
974 parser_run.add_argument(
975 '-1', '--once', action='store_true', help='Only run one step')
976 parser_run.add_argument(
Kuang-che Wu889f68e2018-10-29 14:12:13 +0800977 '--force',
978 action='store_true',
979 help="Run at least once even it's already done")
980 parser_run.add_argument(
Kuang-che Wu88875db2017-07-20 10:47:53 +0800981 'revs',
982 nargs='*',
Kuang-che Wu752228c2018-09-05 13:54:22 +0800983 type=self.domain_cls.intra_revtype,
Kuang-che Wu88875db2017-07-20 10:47:53 +0800984 help='revs to switch+eval; '
985 'default is calculating automatically and run until done')
986 parser_run.set_defaults(func=self.cmd_run)
987
988 parser_switch = subparsers.add_parser(
989 'switch', help='Switch to given rev without eval')
990 parser_switch.add_argument(
Kuang-che Wu603cdad2019-01-18 21:32:55 +0800991 'rev',
992 type=argtype_multiplexer(self.domain_cls.intra_revtype,
993 argtype_re('next', 'next')))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800994 parser_switch.set_defaults(func=self.cmd_switch)
995
996 parser_old = subparsers.add_parser(
997 'old', help='Tells bisect engine the said revs have "old" behavior')
998 parser_old.add_argument(
Kuang-che Wu752228c2018-09-05 13:54:22 +0800999 'revs',
1000 nargs='+',
1001 type=argtype_multiplier(self.domain_cls.intra_revtype))
Kuang-che Wu88875db2017-07-20 10:47:53 +08001002 parser_old.set_defaults(func=self.cmd_old)
1003
1004 parser_new = subparsers.add_parser(
1005 'new', help='Tells bisect engine the said revs have "new" behavior')
1006 parser_new.add_argument(
Kuang-che Wu752228c2018-09-05 13:54:22 +08001007 'revs',
1008 nargs='+',
1009 type=argtype_multiplier(self.domain_cls.intra_revtype))
Kuang-che Wu88875db2017-07-20 10:47:53 +08001010 parser_new.set_defaults(func=self.cmd_new)
1011
1012 parser_skip = subparsers.add_parser(
1013 'skip', help='Tells bisect engine the said revs have "skip" behavior')
1014 parser_skip.add_argument(
Kuang-che Wu752228c2018-09-05 13:54:22 +08001015 'revs',
1016 nargs='+',
1017 type=argtype_multiplier(self.domain_cls.intra_revtype))
Kuang-che Wu88875db2017-07-20 10:47:53 +08001018 parser_skip.set_defaults(func=self.cmd_skip)
1019
1020 parser_view = subparsers.add_parser(
1021 'view', help='Shows current progress and candidates')
Kuang-che Wue80bb872018-11-15 19:45:25 +08001022 parser_view.add_argument('--verbose', '-v', action='store_true')
1023 parser_view.add_argument('--json', action='store_true')
Kuang-che Wu88875db2017-07-20 10:47:53 +08001024 parser_view.set_defaults(func=self.cmd_view)
1025
1026 parser_log = subparsers.add_parser(
1027 'log', help='Prints what has been done so far')
Kuang-che Wu8b654092018-11-09 17:56:25 +08001028 parser_log.add_argument('--before', type=float)
1029 parser_log.add_argument('--after', type=float)
1030 parser_log.add_argument(
1031 '--json', action='store_true', help='Machine readable output')
Kuang-che Wu88875db2017-07-20 10:47:53 +08001032 parser_log.set_defaults(func=self.cmd_log)
1033
1034 parser_next = subparsers.add_parser(
1035 'next', help='Prints next suggested rev to bisect')
1036 parser_next.set_defaults(func=self.cmd_next)
1037
1038 return parser
1039
1040 def main(self, *args, **kwargs):
1041 """Command line main function.
1042
1043 Args:
1044 *args: Command line arguments.
1045 **kwargs: additional non command line arguments passed by script code.
1046 {
1047 'prog': Program name; optional.
1048 }
1049 """
Kuang-che Wu385279d2017-09-27 14:48:28 +08001050 common.init()
Kuang-che Wu88875db2017-07-20 10:47:53 +08001051 parser = self.create_argument_parser(kwargs.get('prog'))
1052 opts = parser.parse_args(args or None)
1053 common.config_logging(opts)
1054
1055 self._create_states(session=opts.session, session_base=opts.session_base)
1056 if opts.command not in ('init', 'reset', 'config'):
1057 self.states.load()
1058 self.domain = self.domain_cls(self.states.config)
Kuang-che Wu4f6f9122019-04-23 17:44:46 +08001059 self.strategy = self._strategy_factory()
Kuang-che Wu88875db2017-07-20 10:47:53 +08001060
1061 return opts.func(opts)