blob: 1eceebe5d592ee24216ac43acc5090d1fe136fd6 [file] [log] [blame]
Kuang-che Wu6e4beca2018-06-27 17:45:02 +08001# -*- coding: utf-8 -*-
Kuang-che Wu88875db2017-07-20 10:47:53 +08002# 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"""Utility functions and classes."""
6
7from __future__ import print_function
Kuang-che Wu11713052019-05-30 16:21:54 +08008import difflib
Kuang-che Wu88875db2017-07-20 10:47:53 +08009import logging
Kuang-che Wu999893c2020-04-13 22:06:22 +080010import queue
Kuang-che Wuaf917102020-04-10 18:03:22 +080011import re
Kuang-che Wu88875db2017-07-20 10:47:53 +080012import subprocess
13import threading
14import time
15
16import psutil
17
Kuang-che Wu44278142019-03-04 11:33:57 +080018from bisect_kit import errors
19
Kuang-che Wu88875db2017-07-20 10:47:53 +080020logger = logging.getLogger(__name__)
21
22
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +080023class TimeoutExpired(Exception):
24 """Timeout expired.
25
26 This may be raised by blocking calls like Popen.wait(), check_call(),
27 check_output(), etc.
28 """
29
30
Kuang-che Wu23192ad2020-03-11 18:12:46 +080031class Popen:
Kuang-che Wu88875db2017-07-20 10:47:53 +080032 """Wrapper of subprocess.Popen. Support output logging.
33
Kuang-che Wu68f022d2019-11-29 14:38:48 +080034 The default is text mode with utf8 encoding. This is different to
35 subprocess.Popen, which is default binary.
36
Kuang-che Wu88875db2017-07-20 10:47:53 +080037 Attributes:
38 duration: Wall time of program execution in seconds.
39 returncode: The child return code.
40 """
41
42 def __init__(self,
43 args,
44 stdout_callback=None,
45 stderr_callback=None,
Kuang-che Wubcafc552019-08-15 15:27:02 +080046 log_stdout=True,
Kuang-che Wu68f022d2019-11-29 14:38:48 +080047 binary=None,
Kuang-che Wu88875db2017-07-20 10:47:53 +080048 **kwargs):
49 """Initializes Popen.
50
51 Args:
52 args: Command line arguments.
53 stdout_callback: Callback function for stdout. Called once per line.
54 stderr_callback: Callback function for stderr. Called once per line.
Kuang-che Wu68f022d2019-11-29 14:38:48 +080055 binary: binary mode; default is False
Kuang-che Wubcafc552019-08-15 15:27:02 +080056 log_stdout: Whether write the stdout output of the child process to log.
Kuang-che Wu88875db2017-07-20 10:47:53 +080057 **kwargs: Additional arguments passing to subprocess.Popen.
58 """
59 if 'stdout' in kwargs:
60 raise ValueError('stdout argument not allowed, it will be overridden.')
61 if 'stderr' in kwargs:
62 raise ValueError('stderr argument not allowed, it will be overridden.')
63
Kuang-che Wu68f022d2019-11-29 14:38:48 +080064 if binary:
65 assert not kwargs.get('encoding')
66 self.binary_mode = True
67 self.encoding = None
68 self.log_stdout = False
69 else:
70 self.binary_mode = False
71 self.encoding = kwargs.get('encoding', 'utf8')
72 self.log_stdout = log_stdout
Kuang-che Wu999893c2020-04-13 22:06:22 +080073 kwargs['encoding'] = self.encoding
Kuang-che Wu68f022d2019-11-29 14:38:48 +080074
Kuang-che Wu88875db2017-07-20 10:47:53 +080075 self.stdout_callback = stdout_callback
76 self.stderr_callback = stderr_callback
Kuang-che Wu88875db2017-07-20 10:47:53 +080077 self.stdout_lines = []
78 self.stderr_lines = []
79 self.duration = -1
80 self.start = time.time()
Kuang-che Wuae6824b2019-08-27 22:20:01 +080081 self.queue = queue.Queue(65536)
Kuang-che Wu88875db2017-07-20 10:47:53 +080082 if isinstance(args, str):
83 logger.debug('cwd=%s, run %r', kwargs.get('cwd'), args)
84 else:
Kuang-che Wu62dda1e2018-03-27 20:37:52 +080085 logger.debug('cwd=%s, run %r', kwargs.get('cwd'),
86 subprocess.list2cmdline(args))
Kuang-che Wu88875db2017-07-20 10:47:53 +080087 self.p = subprocess.Popen(
88 args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
89
90 self.stdout_thread = threading.Thread(
91 target=self._reader_thread, args=('stdout', self.p.stdout))
92 self.stdout_thread.setDaemon(True)
93 self.stdout_thread.start()
94
95 self.stderr_thread = threading.Thread(
96 target=self._reader_thread, args=('stderr', self.p.stderr))
97 self.stderr_thread.setDaemon(True)
98 self.stderr_thread.start()
99
100 @property
101 def returncode(self):
102 return self.p.returncode
103
104 def _reader_thread(self, where, child_file):
105 """Reader thread to help reading stdout and stderr.
106
107 Args:
108 where: 'stdout' or 'stderr'.
109 child_file: file object which producing output.
110 """
111 for line in iter(child_file.readline, ''):
112 self.queue.put((where, line))
113 self.queue.put((where, ''))
Kuang-che Wu74bcb642020-02-20 18:45:53 +0800114 child_file.close()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800115
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800116 def wait(self, timeout=None):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800117 """Waits child process.
118
119 Returns:
120 return code.
121 """
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800122 t0 = time.time()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800123 ended = 0
124 while ended < 2:
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800125 if timeout is not None:
126 try:
127 remaining_time = timeout - (time.time() - t0)
128 if remaining_time > 0:
129 where, line = self.queue.get(block=True, timeout=remaining_time)
130 else:
131 # We follow queue.get's behavior to raise queue.Empty, so it's
132 # always queue.Empty when time is up, no matter remaining_time is
133 # negative or positive.
134 raise queue.Empty
135 except queue.Empty:
136 logger.debug('child process time out (%.1f seconds), kill it',
137 timeout)
138 self.p.kill()
139 raise TimeoutExpired
140 else:
141 where, line = self.queue.get(block=True)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800142 # line includes '\n', will be '' if EOF.
143 if not line:
144 ended += 1
145 continue
Kuang-che Wu88875db2017-07-20 10:47:53 +0800146 if self.stdout_callback and where == 'stdout':
147 self.stdout_callback(line)
148 if self.stderr_callback and where == 'stderr':
149 self.stderr_callback(line)
Kuang-che Wu68f022d2019-11-29 14:38:48 +0800150 if self.log_stdout or where == 'stderr':
151 if self.binary_mode:
152 line = line.decode('utf8', errors='replace')
153 logger.debug('[%s] %s', where, line.rstrip('\n'))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800154 self.p.wait()
155 self.duration = time.time() - self.start
156 logger.debug('returncode %d', self.returncode)
157 return self.returncode
158
159 def terminate(self):
160 """Terminates child and descendant processes."""
161 # Need to ignore failures because sometimes they are expected.
162 # For example, the owner of child process is different to current and
163 # unable to be killed by current process. 'cros_sdk' is one of such case.
Kuang-che Wu455c7342017-11-28 13:21:32 +0800164 for proc in psutil.Process(self.p.pid).children(recursive=True):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800165 try:
166 proc.terminate()
Kuang-che Wu68db08a2018-03-30 11:50:34 +0800167 except psutil.AccessDenied:
Kuang-che Wu88875db2017-07-20 10:47:53 +0800168 logger.warning('Unable to terminate pid=%d; ignore', proc.pid)
169 try:
170 self.p.terminate()
171 except OSError:
172 logger.warning('Unable to terminate pid=%d; ignore', self.p.pid)
173 time.sleep(0.1)
174 try:
175 self.p.kill()
176 except OSError:
177 logger.warning('Unable to kill pid=%d; ignore', self.p.pid)
178
179
180def call(*args, **kwargs):
181 """Run command.
182
183 Modeled after subprocess.call.
184
185 Returns:
186 Exit code of sub-process.
187 """
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800188 timeout = kwargs.get('timeout')
189 # TODO(kcwu): let current function capture this optional parameter after
190 # migrated to python3
191 if 'timeout' in kwargs:
192 del kwargs['timeout']
Kuang-che Wu88875db2017-07-20 10:47:53 +0800193 p = Popen(args, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800194 return p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800195
196
197def check_output(*args, **kwargs):
198 """Runs command and return output.
199
200 Modeled after subprocess.check_output.
201
202 Returns:
203 stdout string of execution.
204
205 Raises:
206 subprocess.CalledProcessError if the exit code is non-zero.
207 """
208 stdout_lines = []
209
210 def collect_stdout(line):
211 stdout_lines.append(line)
212
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800213 timeout = kwargs.get('timeout')
214 # TODO(kcwu): let current function capture this optional parameter after
215 # migrated to python3
216 if 'timeout' in kwargs:
217 del kwargs['timeout']
Kuang-che Wu88875db2017-07-20 10:47:53 +0800218 p = Popen(args, stdout_callback=collect_stdout, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800219 p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800220 stdout = ''.join(stdout_lines)
221 if p.returncode != 0:
222 raise subprocess.CalledProcessError(p.returncode, args, stdout)
223
224 return stdout
225
226
227def check_call(*args, **kwargs):
228 """Runs command and ensures it succeeded.
229
230 Modeled after subprocess.check_call.
231
232 Raises:
233 subprocess.CalledProcessError if the exit code is non-zero.
234 """
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800235 timeout = kwargs.get('timeout')
236 # TODO(kcwu): let current function capture this optional parameter after
237 # migrated to python3
238 if 'timeout' in kwargs:
239 del kwargs['timeout']
Kuang-che Wu88875db2017-07-20 10:47:53 +0800240 p = Popen(args, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800241 p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800242 if p.returncode != 0:
243 raise subprocess.CalledProcessError(p.returncode, args)
244
245
Kuang-che Wu44278142019-03-04 11:33:57 +0800246def ssh_cmd(host, *args, **kwargs):
247 """Runs remote command using ssh.
248
249 Args:
250 host: remote host address
251 args: command and args running on the remote host
252 kwargs:
253 connect_timeout: connection timeout in seconds (int)
254
255 Raises:
256 subprocess.CalledProcessError if the exit code is non-zero.
257 """
258 cmd = ['ssh']
259 if kwargs.get('connect_timeout'):
260 cmd += ['-oConnectTimeout=%d' % kwargs['connect_timeout']]
261 cmd.append(host)
262 cmd += list(args)
263 try:
264 return check_output(*cmd)
265 except subprocess.CalledProcessError as e:
266 # ssh's own error code is 255.
267 if e.returncode == 255:
268 raise errors.SshConnectionError('ssh connection to %r failed' % host)
269 raise
270
271
Kuang-che Wuaf917102020-04-10 18:03:22 +0800272def escape_rev(rev):
273 """Escapes special characters in version string.
274
275 Sometimes we save files whose name is related to version, e.g. cache file and
276 log file. Version strings must be escaped properly in order to make them
277 path-friendly.
278
279 Args:
280 rev: rev string
281
282 Returns:
283 escaped string
284 """
285 # TODO(kcwu): change infra rev format, avoid special characters
286 # Assume they don't collision after escaping.
287 # Don't use "#" because gsutil using it as version identifiers.
288 return re.sub('[^a-zA-Z0-9~._-]', '_', rev)
289
290
Kuang-che Wu88875db2017-07-20 10:47:53 +0800291def version_key_func(v):
292 """Splits version string into components.
293
294 Split version number by '.', and convert to `int` if possible. After this
295 conversion, version numbers can be compared ordering directly. Usually this is
296 used with sort function together.
297
298 Example,
299 >>> version_key_func('1.a.3')
300 [1, 'a', 3]
301
302 Args:
303 v: version string
304
305 Returns:
306 list of int or string
307 """
308 return [int(x) if x.isdigit() else x for x in v.split('.')]
309
310
311def is_version_lesseq(a, b):
312 """Compares whether version `a` is less or equal to version `b`.
313
314 Note this only compares the numeric values component-wise. That is, '1.1' is
315 less than '2.0', but '1.1' may or may not be older than '2.0' according to
316 chromium version semantic.
317
318 Args:
319 a: version string
320 b: version string
321
322 Returns:
323 bool: True if a <= b
324 """
325 return version_key_func(a) <= version_key_func(b)
326
327
328def is_direct_relative_version(a, b):
329 r"""Determines two versions are direct-relative.
330
331 "Direct-relative" means "one is ancestor of the other".
332
333 This follows chromium and chromiumos version semantic.
334 https://www.chromium.org/developers/version-numbers
335
336 That is, [Major+1].[Minor] is a descendant of [Major+1].1, which is branched
337 from [Major+1].0, which is a child of [Major].0. Thus, [Major+1].[Minor] is
338 not direct-relative to any [Major].[Minor>0].
339
340 For example, in this chart, 3.3 is not direct-relative to 2.2.
341
342 -> 2.0 ------------------> 3.0 -------------
343 \ \
344 -> 2.1 -> 2.2 .... -> 3.1 -> 3.2 -> 3.3 ....
345
346 Args:
347 a: version string
348 b: version string
349
350 Returns:
351 bool: True if `a` and `b` are direct-relative.
352 """
353 a = version_key_func(a)
354 b = version_key_func(b)
355 assert len(a) == len(b)
356 if a > b:
357 a, b = b, a
358
359 branched = False
360 for x, y in zip(a, b):
361 if branched:
362 if x != 0:
363 return False
364 elif x != y:
365 branched = True
366
367 return True
Kuang-che Wu11713052019-05-30 16:21:54 +0800368
369
370def show_similar_candidates(key, value, candidates):
371 logger.error('incorrect %s: %r; possible candidates:', key, value)
372 if not candidates:
373 logger.error('(no candidates at all)')
374 return
375 similar_candidates = difflib.get_close_matches(value, candidates)
376 if not similar_candidates:
377 logger.error('(no similar candidates)')
378 return
379 for candidate in similar_candidates:
380 logger.error(' %s', candidate)