blob: 870fae3795546d809f2141fd61d074b9236bc534 [file] [log] [blame]
Kuang-che Wu88875db2017-07-20 10:47:53 +08001#!/usr/bin/env python2
2# 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"""Helper script to report exit code according to execution result."""
6
7from __future__ import print_function
8import argparse
9import collections
10import logging
11import re
12import subprocess
13import sys
14import textwrap
15import time
16
17from bisect_kit import cli
18from bisect_kit import util
19from bisect_kit import common
20
21logger = logging.getLogger(__name__)
22
23OLD = 'old'
24NEW = 'new'
25SKIP = 'skip'
26FATAL = 'fatal'
27
28EXIT_CODE_MAP = {
29 OLD: 0,
30 NEW: 1,
31 SKIP: 125,
32 FATAL: 126,
33}
34
35
36def argtype_ratio(s):
37 """Checks ratio syntax for argument parsing.
38
39 Args:
40 s: argument string.
41
42 Returns:
43 (op, value):
44 op: Operator, should be one of <, <=, =, ==, >, >=.
45 value: Should be between 0 and 1.
46 """
47 m = re.match(r'^([<>=]=?)\s*(\d+(?:\.\d+)?)$', s)
48 if not m:
49 raise argparse.ArgumentTypeError('invalid ratio condition')
50 op, value = m.group(1), float(m.group(2))
51 if not 0 <= value <= 1:
52 raise argparse.ArgumentTypeError('value should be between 0 and 1')
53 return op, value
54
55
56def create_argument_parser():
57 """Creates command line argument parser.
58
59 Returns:
60 An argparse.ArgumentParser instance.
61 """
62 parser = argparse.ArgumentParser(
63 description=textwrap.dedent("""
64 Helper script to report exit code according to execution result.
65
66 If precondition specified but not meet, returns SKIP. Returns WANT
67 only if any conditions meet.
68 """),
69 formatter_class=argparse.RawDescriptionHelpFormatter)
70 common.add_logging_arguments(parser)
71 group = parser.add_mutually_exclusive_group(required=True)
72 group.add_argument(
73 '--new',
74 dest='want',
75 action='store_const',
76 const='new',
77 help='Let WANT=NEW')
78 group.add_argument(
79 '--old',
80 dest='want',
81 action='store_const',
82 const='old',
83 help='Let WANT=OLD')
84 parser.add_argument('exec_cmd')
85 parser.add_argument('exec_args', nargs=argparse.REMAINDER)
86
87 group = parser.add_argument_group(
88 title='Preconditions to match (optional)',
89 description='All specified preconditions must match, '
90 'otherwise return SKIP.')
91 group.add_argument(
92 '--precondition_output',
93 metavar='REGEX',
94 type=re.compile,
95 help='Precondition to match %(metavar)s')
96
97 group = parser.add_argument_group(
98 title='Conditions to match (mutual exclusive, required)',
99 description='If the specified condition matches, return WANT.')
100 group = group.add_mutually_exclusive_group(required=True)
101 group.add_argument(
102 '--output',
103 metavar='REGEX',
104 type=re.compile,
105 help='Regex to match stdout|stderr')
106 group.add_argument('--returncode', type=int, help='Value of exit code')
107 group.add_argument(
108 '--timeout',
109 type=float,
110 metavar='SECONDS',
111 help='If command executes longer than SECONDS secs')
112
113 group = parser.add_argument_group(
114 title='Execution options',
115 description='Controls how to execute, terminate, and how many times')
116 group.add_argument(
117 '--cwd', type=cli.argtype_dir_path, help='Working directory')
118 group.add_argument(
119 '--repeat',
120 type=int,
121 default=1,
122 metavar='NUM',
123 help='Repeat NUM times (default: %(default)s)')
124 group.add_argument(
125 '--ratio',
126 default='=1',
127 metavar='{op}{value}',
128 type=argtype_ratio,
129 help='Match if meet |ratio| condition. Example: ">0", "<0.5", "=1", etc. '
130 '(default: %(default)r)')
131 group.add_argument(
132 '--noshortcut',
133 action='store_true',
134 help="Don't stop earlier if we know ratio will meet or not by calculation"
135 )
136 #parser.add_argument('--time_limit')
137 group.add_argument(
138 '--terminate_output',
139 metavar='REGEX',
140 type=re.compile,
141 help='Once there is one line matching %(metavar)s, '
142 'terminate the running program')
143
144 return parser
145
146
147def opposite(want):
148 return NEW if want == OLD else OLD
149
150
151def run_once(opts):
152 """Runs command once and returns corresponding exit code.
153
154 This is the main function of runner.py. It controls command execution and
155 converts execution result (output, exit code, duration, etc.) to
156 corresponding exit code according to conditions from command line arguments.
157
158 Returns:
159 OLD: Execution result is considered as old behavior.
160 NEW: Execution result is considered as old behavior.
161 SKIP: Preconditions are not meet.
162 FATAL: Fatal errors like command not found.
163 """
164 cmdline = subprocess.list2cmdline([opts.exec_cmd] + opts.exec_args)
165
166 output_result = dict(
167 output_matched=False,
168 precondition_output_matched=False,
169 meet_terminate=False)
170
171 def output_handler(line):
172 if opts.output and not output_result['output_matched']:
173 if opts.output.search(line):
174 logger.debug('matched output')
175 output_result['output_matched'] = True
176 if (opts.precondition_output and
177 not output_result['precondition_output_matched']):
178 if opts.precondition_output.search(line):
179 logger.debug('matched precondition_output')
180 output_result['precondition_output_matched'] = True
181 if opts.terminate_output and not output_result['meet_terminate']:
182 if opts.terminate_output.search(line):
183 logger.debug('terminate condition matched, stop execution')
184 output_result['meet_terminate'] = True
185 p.terminate()
186
187 p = util.Popen(
188 cmdline,
189 cwd=opts.cwd,
190 shell=True,
191 stdout_callback=output_handler,
192 stderr_callback=output_handler)
193
194 logger.debug('returncode %s', p.wait())
195
196 found = False
197 if opts.output and output_result['output_matched']:
198 found = True
199 if opts.timeout and p.duration > opts.timeout:
200 found = True
201 if opts.returncode is not None and opts.returncode == p.returncode:
202 found = True
203
204 if not found and not output_result['meet_terminate']:
205 if p.returncode > 125:
206 logger.warning('fatal return, FATAL')
207 return FATAL
208 if p.returncode < 0:
209 logger.warning('got signal, FATAL')
210 return FATAL
211
212 if opts.precondition_output and \
213 not output_result['precondition_output_matched']:
214 logger.warning("precondition doesn't meet, SKIP")
215 return SKIP
216
217 return opts.want if found else opposite(opts.want)
218
219
220def criteria_is_met(ratio, counter, rest, want):
221 """Determines if current count of exit code meets specified threshold.
222
223 After several runs of execution, `counter` contains how many times the exit
224 code is 'new' or 'old'. This function answers whether the ratio, 'new' versus
225 'old', meets specified threshold.
226
227 For example,
228 >>> criteria_is_met(('>=', 0.9), dict(new=20, old=1), 0, 'new')
229 True
230 It's True because 20/(20+1) >= 0.9.
231
232 This function may be called before all loop iterations are completed (rest >
233 0). For such case, the result (meet or not) is unknown (due to uncertainty)
234 and thus return value is None.
235
236 Args:
237 ratio: (operator, value). For example, ('>=', 0.9) means the calculated
238 value needs to be greater than or equal to 0.9.
239 counter: (dict) count of execution result.
240 rest: remaining execution count.
241 want: the goal metric. 'new' or 'old'.
242
243 Returns:
244 None if the result is unknown, True if the `ratio` condition is meet, False
245 otherwise.
246 """
247 other = opposite(want)
248 if counter[want] + counter[other] == 0:
249 return None
250
251 # Assume no more SKIP.
252 max_run = counter[want] + counter[other] + rest
253 max_possible = float(counter[want] + rest) / max_run
254 min_possible = float(counter[want]) / max_run
255
256 op, v = ratio
257 if op == '>':
258 if max_possible > v >= min_possible:
259 return None
260 return min_possible > v
261 if op == '>=':
262 if max_possible >= v > min_possible:
263 return None
264 return min_possible >= v
265 if op == '<':
266 if min_possible < v <= max_possible:
267 return None
268 return min_possible < v
269 if op == '<=':
270 if min_possible <= v < max_possible:
271 return None
272 return min_possible <= v
273
274 assert op in ('=', '==')
275 # If the final count is not near an integer, it is certain (impossible to
276 # meet).
277 if abs(round(v * max_run) - v * max_run) > 1e-5:
278 return False
279 if min_possible != max_possible and min_possible <= v <= max_possible:
280 return None
281 return abs(min_possible - v) < 1e-3
282
283
284def main(args=None):
285 parser = create_argument_parser()
286 opts = parser.parse_args(args)
287 common.config_logging(opts)
288
289 counter = collections.Counter()
290
291 runtime = []
292 for i in xrange(opts.repeat):
293 t0 = time.time()
294 status = run_once(opts)
295 t1 = time.time()
296 runtime.append(t1 - t0)
297
298 counter[status] += 1
299 logger.info('%(ith)d/%(num)d old=%(old)d, new=%(new)d, skip=%(skip)d;'
300 ' avg time=%(avgtime).2f',
301 dict(
302 ith=i + 1,
303 num=opts.repeat,
304 old=counter[OLD],
305 new=counter[NEW],
306 skip=counter[SKIP],
307 avgtime=float(sum(runtime)) / len(runtime)))
308
309 if status == SKIP:
310 continue
311 if status == FATAL:
312 return FATAL
313 assert status in (OLD, NEW)
314
315 rest = opts.repeat - i - 1
316 meet = criteria_is_met(opts.ratio, counter, rest, opts.want)
317 if rest > 0 and not opts.noshortcut and meet is not None:
318 logger.info('impossible to change result of ratio:"%s", break the loop',
319 opts.ratio)
320 break
321
322 if counter[OLD] == counter[NEW] == 0:
323 status = SKIP
324 elif meet:
325 status = opts.want
326 else:
327 assert meet is not None
328 status = opposite(opts.want)
329
330 logger.info(
331 'Runner final result %(status)s: old=%(old)d, new=%(new)d, skip=%(skip)d;'
332 ' avg time=%(avgtime).2f',
333 dict(
334 status=status,
335 old=counter[OLD],
336 new=counter[NEW],
337 skip=counter[SKIP],
338 avgtime=float(sum(runtime)) / len(runtime)))
339
340 return status
341
342
343if __name__ == '__main__':
344 sys.exit(EXIT_CODE_MAP[main()])