blob: 2584faa60a59aa9e6881078947ded97daa28bdb7 [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 Wu88875db2017-07-20 10:47:53 +080010import subprocess
11import threading
12import time
13
14import psutil
Kuang-che Wu68f022d2019-11-29 14:38:48 +080015import six
Kuang-che Wuae6824b2019-08-27 22:20:01 +080016from six.moves import queue
Kuang-che Wu88875db2017-07-20 10:47:53 +080017
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 Wu88875db2017-07-20 10:47:53 +080031class Popen(object):
32 """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
73
74 if six.PY2:
75 if 'encoding' in kwargs:
76 del kwargs['encoding']
77 else:
78 kwargs['encoding'] = self.encoding
79
Kuang-che Wu88875db2017-07-20 10:47:53 +080080 self.stdout_callback = stdout_callback
81 self.stderr_callback = stderr_callback
Kuang-che Wu88875db2017-07-20 10:47:53 +080082 self.stdout_lines = []
83 self.stderr_lines = []
84 self.duration = -1
85 self.start = time.time()
Kuang-che Wuae6824b2019-08-27 22:20:01 +080086 self.queue = queue.Queue(65536)
Kuang-che Wu88875db2017-07-20 10:47:53 +080087 if isinstance(args, str):
88 logger.debug('cwd=%s, run %r', kwargs.get('cwd'), args)
89 else:
Kuang-che Wu62dda1e2018-03-27 20:37:52 +080090 logger.debug('cwd=%s, run %r', kwargs.get('cwd'),
91 subprocess.list2cmdline(args))
Kuang-che Wu88875db2017-07-20 10:47:53 +080092 self.p = subprocess.Popen(
93 args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
94
95 self.stdout_thread = threading.Thread(
96 target=self._reader_thread, args=('stdout', self.p.stdout))
97 self.stdout_thread.setDaemon(True)
98 self.stdout_thread.start()
99
100 self.stderr_thread = threading.Thread(
101 target=self._reader_thread, args=('stderr', self.p.stderr))
102 self.stderr_thread.setDaemon(True)
103 self.stderr_thread.start()
104
105 @property
106 def returncode(self):
107 return self.p.returncode
108
109 def _reader_thread(self, where, child_file):
110 """Reader thread to help reading stdout and stderr.
111
112 Args:
113 where: 'stdout' or 'stderr'.
114 child_file: file object which producing output.
115 """
116 for line in iter(child_file.readline, ''):
117 self.queue.put((where, line))
118 self.queue.put((where, ''))
119
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800120 def wait(self, timeout=None):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800121 """Waits child process.
122
123 Returns:
124 return code.
125 """
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800126 t0 = time.time()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800127 ended = 0
128 while ended < 2:
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800129 if timeout is not None:
130 try:
131 remaining_time = timeout - (time.time() - t0)
132 if remaining_time > 0:
133 where, line = self.queue.get(block=True, timeout=remaining_time)
134 else:
135 # We follow queue.get's behavior to raise queue.Empty, so it's
136 # always queue.Empty when time is up, no matter remaining_time is
137 # negative or positive.
138 raise queue.Empty
139 except queue.Empty:
140 logger.debug('child process time out (%.1f seconds), kill it',
141 timeout)
142 self.p.kill()
143 raise TimeoutExpired
144 else:
145 where, line = self.queue.get(block=True)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800146 # line includes '\n', will be '' if EOF.
147 if not line:
148 ended += 1
149 continue
Kuang-che Wu88875db2017-07-20 10:47:53 +0800150 if self.stdout_callback and where == 'stdout':
151 self.stdout_callback(line)
152 if self.stderr_callback and where == 'stderr':
153 self.stderr_callback(line)
Kuang-che Wu68f022d2019-11-29 14:38:48 +0800154 if self.log_stdout or where == 'stderr':
155 if self.binary_mode:
156 line = line.decode('utf8', errors='replace')
157 logger.debug('[%s] %s', where, line.rstrip('\n'))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800158 self.p.wait()
159 self.duration = time.time() - self.start
160 logger.debug('returncode %d', self.returncode)
161 return self.returncode
162
163 def terminate(self):
164 """Terminates child and descendant processes."""
165 # Need to ignore failures because sometimes they are expected.
166 # For example, the owner of child process is different to current and
167 # unable to be killed by current process. 'cros_sdk' is one of such case.
Kuang-che Wu455c7342017-11-28 13:21:32 +0800168 for proc in psutil.Process(self.p.pid).children(recursive=True):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800169 try:
170 proc.terminate()
Kuang-che Wu68db08a2018-03-30 11:50:34 +0800171 except psutil.AccessDenied:
Kuang-che Wu88875db2017-07-20 10:47:53 +0800172 logger.warning('Unable to terminate pid=%d; ignore', proc.pid)
173 try:
174 self.p.terminate()
175 except OSError:
176 logger.warning('Unable to terminate pid=%d; ignore', self.p.pid)
177 time.sleep(0.1)
178 try:
179 self.p.kill()
180 except OSError:
181 logger.warning('Unable to kill pid=%d; ignore', self.p.pid)
182
183
184def call(*args, **kwargs):
185 """Run command.
186
187 Modeled after subprocess.call.
188
189 Returns:
190 Exit code of sub-process.
191 """
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800192 timeout = kwargs.get('timeout')
193 # TODO(kcwu): let current function capture this optional parameter after
194 # migrated to python3
195 if 'timeout' in kwargs:
196 del kwargs['timeout']
Kuang-che Wu88875db2017-07-20 10:47:53 +0800197 p = Popen(args, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800198 return p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800199
200
201def check_output(*args, **kwargs):
202 """Runs command and return output.
203
204 Modeled after subprocess.check_output.
205
206 Returns:
207 stdout string of execution.
208
209 Raises:
210 subprocess.CalledProcessError if the exit code is non-zero.
211 """
212 stdout_lines = []
213
214 def collect_stdout(line):
215 stdout_lines.append(line)
216
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800217 timeout = kwargs.get('timeout')
218 # TODO(kcwu): let current function capture this optional parameter after
219 # migrated to python3
220 if 'timeout' in kwargs:
221 del kwargs['timeout']
Kuang-che Wu88875db2017-07-20 10:47:53 +0800222 p = Popen(args, stdout_callback=collect_stdout, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800223 p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800224 stdout = ''.join(stdout_lines)
225 if p.returncode != 0:
226 raise subprocess.CalledProcessError(p.returncode, args, stdout)
227
228 return stdout
229
230
231def check_call(*args, **kwargs):
232 """Runs command and ensures it succeeded.
233
234 Modeled after subprocess.check_call.
235
236 Raises:
237 subprocess.CalledProcessError if the exit code is non-zero.
238 """
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800239 timeout = kwargs.get('timeout')
240 # TODO(kcwu): let current function capture this optional parameter after
241 # migrated to python3
242 if 'timeout' in kwargs:
243 del kwargs['timeout']
Kuang-che Wu88875db2017-07-20 10:47:53 +0800244 p = Popen(args, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800245 p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800246 if p.returncode != 0:
247 raise subprocess.CalledProcessError(p.returncode, args)
248
249
Kuang-che Wu44278142019-03-04 11:33:57 +0800250def ssh_cmd(host, *args, **kwargs):
251 """Runs remote command using ssh.
252
253 Args:
254 host: remote host address
255 args: command and args running on the remote host
256 kwargs:
257 connect_timeout: connection timeout in seconds (int)
258
259 Raises:
260 subprocess.CalledProcessError if the exit code is non-zero.
261 """
262 cmd = ['ssh']
263 if kwargs.get('connect_timeout'):
264 cmd += ['-oConnectTimeout=%d' % kwargs['connect_timeout']]
265 cmd.append(host)
266 cmd += list(args)
267 try:
268 return check_output(*cmd)
269 except subprocess.CalledProcessError as e:
270 # ssh's own error code is 255.
271 if e.returncode == 255:
272 raise errors.SshConnectionError('ssh connection to %r failed' % host)
273 raise
274
275
Kuang-che Wu88875db2017-07-20 10:47:53 +0800276def version_key_func(v):
277 """Splits version string into components.
278
279 Split version number by '.', and convert to `int` if possible. After this
280 conversion, version numbers can be compared ordering directly. Usually this is
281 used with sort function together.
282
283 Example,
284 >>> version_key_func('1.a.3')
285 [1, 'a', 3]
286
287 Args:
288 v: version string
289
290 Returns:
291 list of int or string
292 """
293 return [int(x) if x.isdigit() else x for x in v.split('.')]
294
295
296def is_version_lesseq(a, b):
297 """Compares whether version `a` is less or equal to version `b`.
298
299 Note this only compares the numeric values component-wise. That is, '1.1' is
300 less than '2.0', but '1.1' may or may not be older than '2.0' according to
301 chromium version semantic.
302
303 Args:
304 a: version string
305 b: version string
306
307 Returns:
308 bool: True if a <= b
309 """
310 return version_key_func(a) <= version_key_func(b)
311
312
313def is_direct_relative_version(a, b):
314 r"""Determines two versions are direct-relative.
315
316 "Direct-relative" means "one is ancestor of the other".
317
318 This follows chromium and chromiumos version semantic.
319 https://www.chromium.org/developers/version-numbers
320
321 That is, [Major+1].[Minor] is a descendant of [Major+1].1, which is branched
322 from [Major+1].0, which is a child of [Major].0. Thus, [Major+1].[Minor] is
323 not direct-relative to any [Major].[Minor>0].
324
325 For example, in this chart, 3.3 is not direct-relative to 2.2.
326
327 -> 2.0 ------------------> 3.0 -------------
328 \ \
329 -> 2.1 -> 2.2 .... -> 3.1 -> 3.2 -> 3.3 ....
330
331 Args:
332 a: version string
333 b: version string
334
335 Returns:
336 bool: True if `a` and `b` are direct-relative.
337 """
338 a = version_key_func(a)
339 b = version_key_func(b)
340 assert len(a) == len(b)
341 if a > b:
342 a, b = b, a
343
344 branched = False
345 for x, y in zip(a, b):
346 if branched:
347 if x != 0:
348 return False
349 elif x != y:
350 branched = True
351
352 return True
Kuang-che Wu11713052019-05-30 16:21:54 +0800353
354
355def show_similar_candidates(key, value, candidates):
356 logger.error('incorrect %s: %r; possible candidates:', key, value)
357 if not candidates:
358 logger.error('(no candidates at all)')
359 return
360 similar_candidates = difflib.get_close_matches(value, candidates)
361 if not similar_candidates:
362 logger.error('(no similar candidates)')
363 return
364 for candidate in similar_candidates:
365 logger.error(' %s', candidate)