blob: 553d2ed36ceabe1564d093b0ac9d0d6c225d7b47 [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)
Kuang-che Wu385279d2017-09-27 14:48:28 +080070 common.add_common_arguments(parser)
Kuang-che Wu88875db2017-07-20 10:47:53 +080071 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):
Kuang-che Wu385279d2017-09-27 14:48:28 +0800285 common.init()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800286 parser = create_argument_parser()
287 opts = parser.parse_args(args)
288 common.config_logging(opts)
289
290 counter = collections.Counter()
291
292 runtime = []
293 for i in xrange(opts.repeat):
294 t0 = time.time()
295 status = run_once(opts)
296 t1 = time.time()
297 runtime.append(t1 - t0)
298
299 counter[status] += 1
300 logger.info('%(ith)d/%(num)d old=%(old)d, new=%(new)d, skip=%(skip)d;'
301 ' avg time=%(avgtime).2f',
302 dict(
303 ith=i + 1,
304 num=opts.repeat,
305 old=counter[OLD],
306 new=counter[NEW],
307 skip=counter[SKIP],
308 avgtime=float(sum(runtime)) / len(runtime)))
309
310 if status == SKIP:
311 continue
312 if status == FATAL:
313 return FATAL
314 assert status in (OLD, NEW)
315
316 rest = opts.repeat - i - 1
317 meet = criteria_is_met(opts.ratio, counter, rest, opts.want)
318 if rest > 0 and not opts.noshortcut and meet is not None:
319 logger.info('impossible to change result of ratio:"%s", break the loop',
320 opts.ratio)
321 break
322
323 if counter[OLD] == counter[NEW] == 0:
324 status = SKIP
325 elif meet:
326 status = opts.want
327 else:
328 assert meet is not None
329 status = opposite(opts.want)
330
331 logger.info(
332 'Runner final result %(status)s: old=%(old)d, new=%(new)d, skip=%(skip)d;'
333 ' avg time=%(avgtime).2f',
334 dict(
335 status=status,
336 old=counter[OLD],
337 new=counter[NEW],
338 skip=counter[SKIP],
339 avgtime=float(sum(runtime)) / len(runtime)))
340
341 return status
342
343
344if __name__ == '__main__':
345 sys.exit(EXIT_CODE_MAP[main()])