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