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