blob: d397272b690cdd6f97b69059317bee84cff57fbd [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 summary = {
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800612 'rev_info': [info.to_dict() for info in self.strategy.rev_info],
Kuang-che Wue80bb872018-11-15 19:45:25 +0800613 }
614
Kuang-che Wu948a79c2019-06-19 19:13:56 +0800615 try:
616 old_idx, new_idx = self.strategy.get_range()
617 highlight_old_idx, highlight_new_idx = self.strategy.get_range(
618 self.strategy.confidence / 10.0)
619 except errors.WrongAssumption:
620 pass
621 else:
622 old, new = map(self.states.idx2rev, [old_idx, new_idx])
623 summary.update({
624 'current_range': (old, new),
625 'highlight_range':
626 map(self.states.idx2rev, [highlight_old_idx, highlight_new_idx]),
627 'prob':
628 self.strategy.get_prob(),
629 'remaining_steps':
630 self.strategy.remaining_steps(),
631 })
632
Kuang-che Wue80bb872018-11-15 19:45:25 +0800633 if opts.verbose or opts.json:
634 interesting_indexes = set(range(len(summary['rev_info'])))
Kuang-che Wu948a79c2019-06-19 19:13:56 +0800635 elif 'current_range' not in summary:
636 interesting_indexes = set()
Kuang-che Wue80bb872018-11-15 19:45:25 +0800637 else:
638 interesting_indexes = set([old_idx, new_idx])
Kuang-che Wu15874b62019-01-11 21:10:27 +0800639 if self.strategy.prob:
640 for i, p in enumerate(self.strategy.prob):
641 if p > 0.05:
642 interesting_indexes.add(i)
Kuang-che Wue80bb872018-11-15 19:45:25 +0800643
644 self.domain.fill_candidate_summary(summary, interesting_indexes)
645
646 if opts.json:
647 print(json.dumps(summary, indent=2, sort_keys=True))
648 else:
649 self.show_summary(summary, interesting_indexes, verbose=opts.verbose)
650
651 def show_summary(self, summary, interesting_indexes, verbose=False):
Kuang-che Wuaccf9202019-01-04 15:40:42 +0800652 for link in summary.get('links', []):
653 print('%s: %s' % (link['name'], link['url']))
654 if 'note' in link:
655 print(link['note'])
Kuang-che Wue80bb872018-11-15 19:45:25 +0800656
Kuang-che Wu948a79c2019-06-19 19:13:56 +0800657 if 'current_range' in summary:
658 old, new = summary['current_range']
659 old_idx, new_idx = map(self.states.data['revlist'].index, [old, new])
660 print('Range: (%s, %s], %s revs left' % (old, new, (new_idx - old_idx)))
661 if summary.get('remaining_steps'):
662 print('(roughly %d steps)' % summary['remaining_steps'])
663 else:
664 old_idx, new_idx = None, None
Kuang-che Wue80bb872018-11-15 19:45:25 +0800665
666 for i, rev_info in enumerate(summary['rev_info']):
667 if (not verbose and not old_idx <= i <= new_idx and
668 not rev_info['result_counter']):
669 continue
670
671 detail = []
Kuang-che Wu948a79c2019-06-19 19:13:56 +0800672 if self.strategy.is_noisy() and summary.get('prob'):
Kuang-che Wua8c987f2019-01-18 14:26:43 +0800673 detail.append('%.4f%%' % (summary['prob'][i] * 100))
Kuang-che Wue80bb872018-11-15 19:45:25 +0800674 if rev_info['result_counter']:
675 detail.append(str(rev_info['result_counter']))
676 values = sorted(rev_info['values'])
677 if len(values) == 1:
678 detail.append('%.3f' % values[0])
679 elif len(values) > 1:
680 detail.append('n=%d,avg=%.3f,median=%.3f,min=%.3f,max=%.3f' %
681 (len(values), sum(values) / len(values),
682 values[len(values) // 2], values[0], values[-1]))
683
684 print('[%d] %s\t%s' % (i, rev_info['rev'], ' '.join(detail)))
685 if i in interesting_indexes:
686 if 'comment' in rev_info:
687 print('\t%s' % rev_info['comment'])
688 for action in rev_info.get('actions', []):
689 if 'text' in action:
690 print('\t%s' % action['text'])
691 if 'link' in action:
692 print('\t%s' % action['link'])
Kuang-che Wu88875db2017-07-20 10:47:53 +0800693
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800694 def _strategy_factory(self):
695 rev_info = self.states.load_rev_info()
696 assert rev_info
697 return strategy.NoisyBinarySearch(
698 rev_info,
699 self.states.rev2idx(self.config['old']),
700 self.states.rev2idx(self.config['new']),
701 old_value=self.config['old_value'],
702 new_value=self.config['new_value'],
703 recompute_init_values=self.config['recompute_init_values'],
704 confidence=self.config['confidence'],
705 observation=self.config['noisy'])
706
Kuang-che Wu88875db2017-07-20 10:47:53 +0800707 def current_status(self, session=None, session_base=None):
708 """Gets current bisect status.
709
710 Returns:
711 A dict describing current status. It contains following items:
712 inited: True iff the session file is initialized (init command has been
713 invoked). If not, below items are omitted.
714 old: Start of current estimated range.
715 new: End of current estimated range.
Kuang-che Wu8b654092018-11-09 17:56:25 +0800716 verified: The bisect range is already verified.
Kuang-che Wu88875db2017-07-20 10:47:53 +0800717 estimated_noise: New estimated noise.
718 done: True if bisection is done, otherwise False.
719 """
720 self._create_states(session=session, session_base=session_base)
721 if self.states.load():
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800722 self.strategy = self._strategy_factory()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800723 left, right = self.strategy.get_range()
724 estimated_noise = self.strategy.get_noise_observation()
725
726 result = dict(
727 inited=True,
728 old=self.states.idx2rev(left),
729 new=self.states.idx2rev(right),
Kuang-che Wu8b654092018-11-09 17:56:25 +0800730 verified=self.strategy.is_range_verified(),
Kuang-che Wudd7f6f02018-06-28 18:19:30 +0800731 estimated_noise=estimated_noise,
Kuang-che Wu88875db2017-07-20 10:47:53 +0800732 done=self.strategy.is_done())
733 else:
734 result = dict(inited=False)
735 return result
736
Kuang-che Wu8b654092018-11-09 17:56:25 +0800737 def cmd_log(self, opts):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800738 """Prints what has been done so far."""
Kuang-che Wu8b654092018-11-09 17:56:25 +0800739 history = []
Kuang-che Wu88875db2017-07-20 10:47:53 +0800740 for entry in self.states.data['history']:
Kuang-che Wu8b654092018-11-09 17:56:25 +0800741 if opts.before and entry['timestamp'] >= opts.before:
742 continue
743 if opts.after and entry['timestamp'] <= opts.after:
744 continue
745 history.append(entry)
746
747 if opts.json:
748 print(json.dumps(history, indent=2))
749 return
750
751 for entry in history:
752 entry_time = datetime.datetime.fromtimestamp(int(entry['timestamp']))
753 if entry.get('event', 'sample') == 'sample':
754 print('{datetime} {rev} {status} {values} {comment}'.format(
755 datetime=entry_time,
756 rev=entry['rev'],
757 status=entry['status'] + ('*%d' % entry['times']
758 if entry.get('times', 1) > 1 else ''),
759 values=entry.get('values', ''),
760 comment=entry.get('comment', '')))
761 else:
762 print('%s %r' % (entry_time, entry))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800763
764 def cmd_next(self, _opts):
765 """Prints next suggested rev to bisect."""
Kuang-che Wu88875db2017-07-20 10:47:53 +0800766 if self.strategy.is_done():
767 print('done')
768 return
769
770 idx = self.strategy.next_idx()
771 rev = self.states.idx2rev(idx)
772 print(rev)
773
774 def cmd_switch(self, opts):
775 """Switches to given rev without eval."""
776 assert self.config.get('switch')
777
Kuang-che Wu88875db2017-07-20 10:47:53 +0800778 if opts.rev == 'next':
779 idx = self.strategy.next_idx()
780 rev = self.states.idx2rev(idx)
781 else:
Kuang-che Wu752228c2018-09-05 13:54:22 +0800782 rev = self.domain_cls.intra_revtype(opts.rev)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800783 assert rev
784
785 logger.info('switch to %s', rev)
786 status = do_switch(self.config['switch'], self.domain, rev)
787 if status:
788 print('switch failed')
789
790 def _add_revs_status_helper(self, revs, status):
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800791 if self.strategy.is_value_bisection():
792 assert status not in ('old', 'new')
Kuang-che Wu88875db2017-07-20 10:47:53 +0800793 for rev, times in revs:
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800794 idx = self.states.rev2idx(rev)
795 sample = {'rev': rev, 'status': status}
796 # times=1 is default in the loader. Add 'times' entry only if necessary
797 # in order to simplify the dict.
798 if times > 1:
799 sample['times'] = times
800 self.states.add_history('sample', **sample)
801 self.states.save()
802 self.strategy.add_sample(idx, **sample)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800803
804 def cmd_new(self, opts):
805 """Tells bisect engine the said revs have "new" behavior."""
806 logger.info('set [%s] as new', opts.revs)
807 self._add_revs_status_helper(opts.revs, 'new')
808
809 def cmd_old(self, opts):
810 """Tells bisect engine the said revs have "old" behavior."""
811 logger.info('set [%s] as old', opts.revs)
812 self._add_revs_status_helper(opts.revs, 'old')
813
814 def cmd_skip(self, opts):
815 """Tells bisect engine the said revs have "skip" behavior."""
816 logger.info('set [%s] as skip', opts.revs)
817 self._add_revs_status_helper(opts.revs, 'skip')
818
819 def _create_states(self, session=None, session_base=None):
820 if not session:
821 session = DEFAULT_SESSION_NAME
822 if not session_base:
Kuang-che Wu41e8b592018-09-25 17:01:30 +0800823 session_base = configure.get('SESSION_BASE', common.DEFAULT_SESSION_BASE)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800824
825 session_file = os.path.join(session_base, session, self.domain_cls.__name__)
826
827 if self.states:
828 assert self.states.session_file == session_file
829 else:
Kuang-che Wuc5781932018-10-05 00:30:19 +0800830 self.states = core.BisectStates(session_file)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800831
832 def cmd_config(self, opts):
833 """Configures additional setting.
834
835 See config command's help message for more detail.
836 """
837 self.states.load()
838 self.domain = self.domain_cls(self.states.config)
839 if not opts.value:
840 print(self.states.config[opts.key])
841 return
842
843 if opts.key in ['switch', 'eval']:
Kuang-che Wu88518882017-09-22 16:57:25 +0800844 result = check_executable(opts.value[0])
845 if result:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800846 raise errors.ArgumentError('%s command' % opts.key, result)
Kuang-che Wu88518882017-09-22 16:57:25 +0800847
Kuang-che Wu88875db2017-07-20 10:47:53 +0800848 self.states.config[opts.key] = opts.value
849
850 elif opts.key == 'confidence':
851 if len(opts.value) != 1:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800852 raise errors.ArgumentError(
853 'confidence value',
854 'expected 1 value, %d values given' % len(opts.value))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800855 try:
856 self.states.config[opts.key] = float(opts.value[0])
857 except ValueError:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800858 raise errors.ArgumentError('confidence value',
859 'invalid float value: %r' % opts.value[0])
Kuang-che Wu88875db2017-07-20 10:47:53 +0800860
861 elif opts.key == 'noisy':
862 if len(opts.value) != 1:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800863 raise errors.ArgumentError(
864 'noisy value',
865 'expected 1 value, %d values given' % len(opts.value))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800866 self.states.config[opts.key] = opts.value[0]
867
868 else:
Kuang-che Wue121fae2018-11-09 16:18:39 +0800869 # unreachable
870 assert 0
Kuang-che Wu88875db2017-07-20 10:47:53 +0800871
872 self.states.save()
873
874 def create_argument_parser(self, prog):
Kuang-che Wub2376262017-11-20 18:05:24 +0800875 if self.domain_cls.help:
876 description = self.domain_cls.help
877 else:
878 description = 'Bisector for %s' % self.domain_cls.__name__
Kuang-che Wuae6824b2019-08-27 22:20:01 +0800879 description += textwrap.dedent("""
Kuang-che Wub2376262017-11-20 18:05:24 +0800880 When running switcher and evaluator, it will set BISECT_REV environment
881 variable, indicates current rev to switch/evaluate.
Kuang-che Wuae6824b2019-08-27 22:20:01 +0800882 """)
Kuang-che Wub2376262017-11-20 18:05:24 +0800883
Kuang-che Wu88875db2017-07-20 10:47:53 +0800884 parser = argparse.ArgumentParser(
885 prog=prog,
886 formatter_class=argparse.RawDescriptionHelpFormatter,
Kuang-che Wub2376262017-11-20 18:05:24 +0800887 description=description)
Kuang-che Wu385279d2017-09-27 14:48:28 +0800888 common.add_common_arguments(parser)
889 parser.add_argument(
Kuang-che Wu88875db2017-07-20 10:47:53 +0800890 '--session_base',
Kuang-che Wu41e8b592018-09-25 17:01:30 +0800891 default=configure.get('SESSION_BASE', common.DEFAULT_SESSION_BASE),
Kuang-che Wu88875db2017-07-20 10:47:53 +0800892 help='Directory to store sessions (default: %(default)r)')
893 parser.add_argument(
894 '--session',
895 default=DEFAULT_SESSION_NAME,
896 help='Session name (default: %(default)r)')
897 subparsers = parser.add_subparsers(
898 dest='command', title='commands', metavar='<command>')
899
900 parser_reset = subparsers.add_parser(
901 'reset', help='Reset bisect session and clean up saved result')
902 parser_reset.set_defaults(func=self.cmd_reset)
903
904 parser_init = subparsers.add_parser(
905 'init',
906 help='Initializes bisect session',
907 formatter_class=argparse.RawDescriptionHelpFormatter,
Kuang-che Wuae6824b2019-08-27 22:20:01 +0800908 description=textwrap.dedent("""
Kuang-che Wu88875db2017-07-20 10:47:53 +0800909 Besides arguments for 'init' command, you also need to set 'switch'
910 and 'eval' command line via 'config' command.
911 $ bisector config switch <switch command and arguments>
912 $ bisector config eval <eval command and arguments>
913
914 The value of --noisy and --confidence could be changed by 'config'
915 command after 'init' as well.
Kuang-che Wuae6824b2019-08-27 22:20:01 +0800916 """))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800917 parser_init.add_argument(
918 '--old',
919 required=True,
920 type=self.domain_cls.revtype,
921 help='Start of bisect range, which has old behavior')
922 parser_init.add_argument(
923 '--new',
924 required=True,
925 type=self.domain_cls.revtype,
926 help='End of bisect range, which has new behavior')
927 parser_init.add_argument(
928 '--noisy',
929 help='Enable noisy binary search and specify prior result. '
930 'For example, "old=1/10,new=2/3" means old fail rate is 1/10 '
931 'and new fail rate increased to 2/3. '
932 'Skip if not flaky, say, "new=2/3" means old is always good.')
933 parser_init.add_argument(
Kuang-che Wu81cde452019-04-08 16:56:51 +0800934 '--old_value',
935 type=float,
936 help='For performance test, value of old behavior')
937 parser_init.add_argument(
938 '--new_value',
939 type=float,
940 help='For performance test, value of new behavior')
941 parser_init.add_argument(
Kuang-che Wu4f6f9122019-04-23 17:44:46 +0800942 '--recompute_init_values',
943 action='store_true',
944 help='For performance test, recompute initial values')
945 parser_init.add_argument(
Kuang-che Wu88875db2017-07-20 10:47:53 +0800946 '--confidence',
947 type=float,
948 default=DEFAULT_CONFIDENCE,
949 help='Confidence level (default: %(default)r)')
950 parser_init.set_defaults(func=self.cmd_init)
951 self.domain_cls.add_init_arguments(parser_init)
952
953 parser_config = subparsers.add_parser(
954 'config', help='Configures additional setting')
955 parser_config.add_argument(
956 'key',
957 choices=['switch', 'eval', 'confidence', 'noisy'],
958 metavar='key',
959 help='What config to change. choices=[%(choices)s]')
960 parser_config.add_argument(
961 'value', nargs=argparse.REMAINDER, help='New value')
962 parser_config.set_defaults(func=self.cmd_config)
963
964 parser_run = subparsers.add_parser(
965 'run',
966 help='Performs bisection',
967 formatter_class=argparse.RawDescriptionHelpFormatter,
Kuang-che Wuae6824b2019-08-27 22:20:01 +0800968 description=textwrap.dedent("""
Kuang-che Wu88875db2017-07-20 10:47:53 +0800969 This command does switch and eval to determine candidates having old or
970 new behavior.
971
972 By default, it attempts to try versions in binary search manner until
973 found the first version having new behavior.
974
975 If version numbers are specified on command line, it just tries those
976 versions and record the result.
977
978 Example:
979 Bisect automatically.
980 $ %(prog)s
981
982 Switch and run version "2.13" and "2.14" and then stop.
983 $ %(prog)s 2.13 2.14
Kuang-che Wuae6824b2019-08-27 22:20:01 +0800984 """))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800985 parser_run.add_argument(
986 '-1', '--once', action='store_true', help='Only run one step')
987 parser_run.add_argument(
Kuang-che Wu889f68e2018-10-29 14:12:13 +0800988 '--force',
989 action='store_true',
990 help="Run at least once even it's already done")
991 parser_run.add_argument(
Kuang-che Wu88875db2017-07-20 10:47:53 +0800992 'revs',
993 nargs='*',
Kuang-che Wu752228c2018-09-05 13:54:22 +0800994 type=self.domain_cls.intra_revtype,
Kuang-che Wu88875db2017-07-20 10:47:53 +0800995 help='revs to switch+eval; '
996 'default is calculating automatically and run until done')
997 parser_run.set_defaults(func=self.cmd_run)
998
999 parser_switch = subparsers.add_parser(
1000 'switch', help='Switch to given rev without eval')
1001 parser_switch.add_argument(
Kuang-che Wu603cdad2019-01-18 21:32:55 +08001002 'rev',
1003 type=argtype_multiplexer(self.domain_cls.intra_revtype,
1004 argtype_re('next', 'next')))
Kuang-che Wu88875db2017-07-20 10:47:53 +08001005 parser_switch.set_defaults(func=self.cmd_switch)
1006
1007 parser_old = subparsers.add_parser(
1008 'old', help='Tells bisect engine the said revs have "old" behavior')
1009 parser_old.add_argument(
Kuang-che Wu752228c2018-09-05 13:54:22 +08001010 'revs',
1011 nargs='+',
1012 type=argtype_multiplier(self.domain_cls.intra_revtype))
Kuang-che Wu88875db2017-07-20 10:47:53 +08001013 parser_old.set_defaults(func=self.cmd_old)
1014
1015 parser_new = subparsers.add_parser(
1016 'new', help='Tells bisect engine the said revs have "new" behavior')
1017 parser_new.add_argument(
Kuang-che Wu752228c2018-09-05 13:54:22 +08001018 'revs',
1019 nargs='+',
1020 type=argtype_multiplier(self.domain_cls.intra_revtype))
Kuang-che Wu88875db2017-07-20 10:47:53 +08001021 parser_new.set_defaults(func=self.cmd_new)
1022
1023 parser_skip = subparsers.add_parser(
1024 'skip', help='Tells bisect engine the said revs have "skip" behavior')
1025 parser_skip.add_argument(
Kuang-che Wu752228c2018-09-05 13:54:22 +08001026 'revs',
1027 nargs='+',
1028 type=argtype_multiplier(self.domain_cls.intra_revtype))
Kuang-che Wu88875db2017-07-20 10:47:53 +08001029 parser_skip.set_defaults(func=self.cmd_skip)
1030
1031 parser_view = subparsers.add_parser(
1032 'view', help='Shows current progress and candidates')
Kuang-che Wue80bb872018-11-15 19:45:25 +08001033 parser_view.add_argument('--verbose', '-v', action='store_true')
1034 parser_view.add_argument('--json', action='store_true')
Kuang-che Wu88875db2017-07-20 10:47:53 +08001035 parser_view.set_defaults(func=self.cmd_view)
1036
1037 parser_log = subparsers.add_parser(
1038 'log', help='Prints what has been done so far')
Kuang-che Wu8b654092018-11-09 17:56:25 +08001039 parser_log.add_argument('--before', type=float)
1040 parser_log.add_argument('--after', type=float)
1041 parser_log.add_argument(
1042 '--json', action='store_true', help='Machine readable output')
Kuang-che Wu88875db2017-07-20 10:47:53 +08001043 parser_log.set_defaults(func=self.cmd_log)
1044
1045 parser_next = subparsers.add_parser(
1046 'next', help='Prints next suggested rev to bisect')
1047 parser_next.set_defaults(func=self.cmd_next)
1048
1049 return parser
1050
1051 def main(self, *args, **kwargs):
1052 """Command line main function.
1053
1054 Args:
1055 *args: Command line arguments.
1056 **kwargs: additional non command line arguments passed by script code.
1057 {
1058 'prog': Program name; optional.
1059 }
1060 """
Kuang-che Wu385279d2017-09-27 14:48:28 +08001061 common.init()
Kuang-che Wu88875db2017-07-20 10:47:53 +08001062 parser = self.create_argument_parser(kwargs.get('prog'))
1063 opts = parser.parse_args(args or None)
1064 common.config_logging(opts)
1065
1066 self._create_states(session=opts.session, session_base=opts.session_base)
1067 if opts.command not in ('init', 'reset', 'config'):
1068 self.states.load()
1069 self.domain = self.domain_cls(self.states.config)
Kuang-che Wu4f6f9122019-04-23 17:44:46 +08001070 self.strategy = self._strategy_factory()
Kuang-che Wu88875db2017-07-20 10:47:53 +08001071
1072 return opts.func(opts)