blob: 1ee40b73be3094bc38dc6b294b04e6eadbe7cbaf [file] [log] [blame]
Kuang-che Wu875c89a2020-01-08 14:30:55 +08001#!/usr/bin/env python3
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 """
Kuang-che Wud2d6e412021-01-28 16:26:41 +080063 parents = [common.common_argument_parser, common.session_optional_parser]
Kuang-che Wu88875db2017-07-20 10:47:53 +080064 parser = argparse.ArgumentParser(
65 description=textwrap.dedent("""
66 Helper script to report exit code according to execution result.
67
68 If precondition specified but not meet, returns SKIP. Returns WANT
69 only if any conditions meet.
70 """),
Kuang-che Wud2d6e412021-01-28 16:26:41 +080071 formatter_class=argparse.RawDescriptionHelpFormatter,
72 parents=parents)
Kuang-che Wufe1e88a2019-09-10 21:52:25 +080073 cli.patching_argparser_exit(parser)
Kuang-che Wu88875db2017-07-20 10:47:53 +080074 group = parser.add_mutually_exclusive_group(required=True)
75 group.add_argument(
76 '--new',
77 dest='want',
78 action='store_const',
79 const='new',
80 help='Let WANT=NEW')
81 group.add_argument(
82 '--old',
83 dest='want',
84 action='store_const',
85 const='old',
86 help='Let WANT=OLD')
87 parser.add_argument('exec_cmd')
88 parser.add_argument('exec_args', nargs=argparse.REMAINDER)
89
90 group = parser.add_argument_group(
91 title='Preconditions to match (optional)',
92 description='All specified preconditions must match, '
93 'otherwise return SKIP.')
94 group.add_argument(
95 '--precondition_output',
96 metavar='REGEX',
97 type=re.compile,
98 help='Precondition to match %(metavar)s')
99
100 group = parser.add_argument_group(
101 title='Conditions to match (mutual exclusive, required)',
102 description='If the specified condition matches, return WANT.')
103 group = group.add_mutually_exclusive_group(required=True)
104 group.add_argument(
105 '--output',
106 metavar='REGEX',
107 type=re.compile,
108 help='Regex to match stdout|stderr')
109 group.add_argument('--returncode', type=int, help='Value of exit code')
110 group.add_argument(
111 '--timeout',
112 type=float,
113 metavar='SECONDS',
114 help='If command executes longer than SECONDS secs')
115
116 group = parser.add_argument_group(
117 title='Execution options',
118 description='Controls how to execute, terminate, and how many times')
119 group.add_argument(
120 '--cwd', type=cli.argtype_dir_path, help='Working directory')
121 group.add_argument(
122 '--repeat',
123 type=int,
124 default=1,
125 metavar='NUM',
126 help='Repeat NUM times (default: %(default)s)')
127 group.add_argument(
128 '--ratio',
129 default='=1',
130 metavar='{op}{value}',
131 type=argtype_ratio,
132 help='Match if meet |ratio| condition. Example: ">0", "<0.5", "=1", etc. '
133 '(default: %(default)r)')
134 group.add_argument(
135 '--noshortcut',
136 action='store_true',
137 help="Don't stop earlier if we know ratio will meet or not by calculation"
138 )
Kuang-che Wu88875db2017-07-20 10:47:53 +0800139 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
Kuang-che Wu25fec6f2021-01-28 12:40:43 +0800294 meet = None
Kuang-che Wu88875db2017-07-20 10:47:53 +0800295 runtime = []
Kuang-che Wuae6824b2019-08-27 22:20:01 +0800296 for i in range(opts.repeat):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800297 t0 = time.time()
298 status = run_once(opts)
299 t1 = time.time()
300 runtime.append(t1 - t0)
301
302 counter[status] += 1
Kuang-che Wu74768d32018-09-07 12:03:24 +0800303 logger.info(
304 '%(ith)d/%(num)d old=%(old)d, new=%(new)d, skip=%(skip)d;'
305 ' avg time=%(avgtime).2f',
306 dict(
307 ith=i + 1,
308 num=opts.repeat,
309 old=counter[OLD],
310 new=counter[NEW],
311 skip=counter[SKIP],
312 avgtime=float(sum(runtime)) / len(runtime)))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800313
314 if status == SKIP:
315 continue
316 if status == FATAL:
317 return FATAL
318 assert status in (OLD, NEW)
319
320 rest = opts.repeat - i - 1
321 meet = criteria_is_met(opts.ratio, counter, rest, opts.want)
322 if rest > 0 and not opts.noshortcut and meet is not None:
323 logger.info('impossible to change result of ratio:"%s", break the loop',
324 opts.ratio)
325 break
326
327 if counter[OLD] == counter[NEW] == 0:
328 status = SKIP
329 elif meet:
330 status = opts.want
331 else:
332 assert meet is not None
333 status = opposite(opts.want)
334
335 logger.info(
336 'Runner final result %(status)s: old=%(old)d, new=%(new)d, skip=%(skip)d;'
337 ' avg time=%(avgtime).2f',
338 dict(
339 status=status,
340 old=counter[OLD],
341 new=counter[NEW],
342 skip=counter[SKIP],
343 avgtime=float(sum(runtime)) / len(runtime)))
344
345 return status
346
347
348if __name__ == '__main__':
349 sys.exit(EXIT_CODE_MAP[main()])