blob: 7f6dd999f479f2a4e1cf44e2748dc2100286a654 [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 """
Kuang-che Wu13acc7b2020-06-15 10:45:35 +0800111 for line in child_file:
Kuang-che Wu88875db2017-07-20 10:47:53 +0800112 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()
Kuang-che Wu6d91b8c2020-11-24 20:14:35 +0800139 raise TimeoutExpired from None
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800140 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
Kuang-che Wu6bf6ac32020-04-15 01:14:32 +0800180def call(*args, timeout=None, **kwargs):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800181 """Run command.
182
183 Modeled after subprocess.call.
184
185 Returns:
186 Exit code of sub-process.
187 """
188 p = Popen(args, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800189 return p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800190
191
Kuang-che Wu6bf6ac32020-04-15 01:14:32 +0800192def check_output(*args, timeout=None, **kwargs):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800193 """Runs command and return output.
194
195 Modeled after subprocess.check_output.
196
197 Returns:
198 stdout string of execution.
199
200 Raises:
201 subprocess.CalledProcessError if the exit code is non-zero.
202 """
203 stdout_lines = []
204
205 def collect_stdout(line):
206 stdout_lines.append(line)
207
208 p = Popen(args, stdout_callback=collect_stdout, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800209 p.wait(timeout=timeout)
Kuang-che Wu13acc7b2020-06-15 10:45:35 +0800210 if kwargs.get('binary'):
211 stdout = b''.join(stdout_lines)
212 else:
213 stdout = ''.join(stdout_lines)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800214 if p.returncode != 0:
215 raise subprocess.CalledProcessError(p.returncode, args, stdout)
216
217 return stdout
218
219
Kuang-che Wu6bf6ac32020-04-15 01:14:32 +0800220def check_call(*args, timeout=None, **kwargs):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800221 """Runs command and ensures it succeeded.
222
223 Modeled after subprocess.check_call.
224
225 Raises:
226 subprocess.CalledProcessError if the exit code is non-zero.
227 """
228 p = Popen(args, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800229 p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800230 if p.returncode != 0:
231 raise subprocess.CalledProcessError(p.returncode, args)
232
233
Kuang-che Wu342d3e02021-02-19 15:40:57 +0800234def ssh_cmd(host, *args, connect_timeout=None, allow_retry=False):
Kuang-che Wu44278142019-03-04 11:33:57 +0800235 """Runs remote command using ssh.
236
237 Args:
238 host: remote host address
239 args: command and args running on the remote host
Kuang-che Wu342d3e02021-02-19 15:40:57 +0800240 connect_timeout: connection timeout in seconds (int)
241 allow_retry: if True, retry when ssh connection failed
Kuang-che Wu44278142019-03-04 11:33:57 +0800242
243 Raises:
244 subprocess.CalledProcessError if the exit code is non-zero.
245 """
246 cmd = ['ssh']
Kuang-che Wu8c4e03c2020-10-21 19:53:31 +0800247 # Avoid keyboard-interactive to prevent hang forever.
248 cmd += ['-oPreferredAuthentications=publickey']
Kuang-che Wu342d3e02021-02-19 15:40:57 +0800249 if connect_timeout:
250 cmd += ['-oConnectTimeout=%d' % connect_timeout]
Kuang-che Wu44278142019-03-04 11:33:57 +0800251 cmd.append(host)
252 cmd += list(args)
Kuang-che Wu342d3e02021-02-19 15:40:57 +0800253
254 tries = 0
255 while True:
256 tries += 1
257 try:
258 return check_output(*cmd)
259 except subprocess.CalledProcessError as e:
260 # ssh's own error code is 255. For other codes, they are returned from
261 # the remote command.
262 if e.returncode != 255:
263 raise
264 if allow_retry and tries < 3:
265 # ssh's ConnectionAttempts is not enough because we want to deal with
266 # situations needing several seconds to recover.
267 delay = 60
268 logger.warning('ssh connection failed, will retry %d seconds later',
269 delay)
270 time.sleep(delay)
271 continue
Kuang-che Wu44278142019-03-04 11:33:57 +0800272 raise errors.SshConnectionError('ssh connection to %r failed' % host)
Kuang-che Wu44278142019-03-04 11:33:57 +0800273
274
Zheng-Jie Changfc5d8742021-03-31 04:57:43 +0800275def scp_cmd(from_file, to_file, connect_timeout=None, allow_retry=False):
276 """Copies a file through scp.
277
278 Args:
279 from_file: from file name
280 to_file: target file name
281 connect_timeout: connection timeout in seconds (int)
282 allow_retry: if True, retry when ssh connection failed
283
284 Raises:
285 subprocess.CalledProcessError if the exit code is non-zero.
286 """
287 cmd = ['scp']
288 # Avoid keyboard-interactive to prevent hang forever.
289 cmd += ['-oPreferredAuthentications=publickey']
290 if connect_timeout:
291 cmd += ['-oConnectTimeout=%d' % connect_timeout]
292 cmd += [
293 from_file,
294 to_file,
295 ]
296
297 tries = 0
298 while True:
299 tries += 1
300 try:
301 return check_output(*cmd)
302 except subprocess.CalledProcessError as e:
303 # ssh's own error code is 255. For other codes, they are returned from
304 # the remote command.
305 if e.returncode != 255:
306 raise
307 if allow_retry and tries < 3:
308 # ssh's ConnectionAttempts is not enough because we want to deal with
309 # situations needing several seconds to recover.
310 delay = 60
311 logger.warning('ssh connection failed, will retry %d seconds later',
312 delay)
313 time.sleep(delay)
314 continue
315 raise errors.SshConnectionError('scp %s to %s failed' %
316 (from_file, to_file))
317
318
Kuang-che Wuaf917102020-04-10 18:03:22 +0800319def escape_rev(rev):
320 """Escapes special characters in version string.
321
322 Sometimes we save files whose name is related to version, e.g. cache file and
323 log file. Version strings must be escaped properly in order to make them
324 path-friendly.
325
326 Args:
327 rev: rev string
328
329 Returns:
330 escaped string
331 """
332 # TODO(kcwu): change infra rev format, avoid special characters
333 # Assume they don't collision after escaping.
334 # Don't use "#" because gsutil using it as version identifiers.
335 return re.sub('[^a-zA-Z0-9~._-]', '_', rev)
336
337
Kuang-che Wu88875db2017-07-20 10:47:53 +0800338def version_key_func(v):
339 """Splits version string into components.
340
341 Split version number by '.', and convert to `int` if possible. After this
342 conversion, version numbers can be compared ordering directly. Usually this is
343 used with sort function together.
344
345 Example,
346 >>> version_key_func('1.a.3')
347 [1, 'a', 3]
348
349 Args:
350 v: version string
351
352 Returns:
353 list of int or string
354 """
355 return [int(x) if x.isdigit() else x for x in v.split('.')]
356
357
358def is_version_lesseq(a, b):
359 """Compares whether version `a` is less or equal to version `b`.
360
361 Note this only compares the numeric values component-wise. That is, '1.1' is
362 less than '2.0', but '1.1' may or may not be older than '2.0' according to
363 chromium version semantic.
364
365 Args:
366 a: version string
367 b: version string
368
369 Returns:
370 bool: True if a <= b
371 """
372 return version_key_func(a) <= version_key_func(b)
373
374
375def is_direct_relative_version(a, b):
376 r"""Determines two versions are direct-relative.
377
378 "Direct-relative" means "one is ancestor of the other".
379
380 This follows chromium and chromiumos version semantic.
381 https://www.chromium.org/developers/version-numbers
382
383 That is, [Major+1].[Minor] is a descendant of [Major+1].1, which is branched
384 from [Major+1].0, which is a child of [Major].0. Thus, [Major+1].[Minor] is
385 not direct-relative to any [Major].[Minor>0].
386
387 For example, in this chart, 3.3 is not direct-relative to 2.2.
388
389 -> 2.0 ------------------> 3.0 -------------
390 \ \
391 -> 2.1 -> 2.2 .... -> 3.1 -> 3.2 -> 3.3 ....
392
Kuang-che Wu430c5282021-01-27 21:10:25 +0800393 Note, one version is direct-relative to itself.
394
Kuang-che Wu88875db2017-07-20 10:47:53 +0800395 Args:
396 a: version string
397 b: version string
398
399 Returns:
400 bool: True if `a` and `b` are direct-relative.
401 """
402 a = version_key_func(a)
403 b = version_key_func(b)
404 assert len(a) == len(b)
405 if a > b:
406 a, b = b, a
407
408 branched = False
409 for x, y in zip(a, b):
410 if branched:
411 if x != 0:
412 return False
413 elif x != y:
414 branched = True
415
416 return True
Kuang-che Wu11713052019-05-30 16:21:54 +0800417
418
419def show_similar_candidates(key, value, candidates):
420 logger.error('incorrect %s: %r; possible candidates:', key, value)
421 if not candidates:
422 logger.error('(no candidates at all)')
423 return
424 similar_candidates = difflib.get_close_matches(value, candidates)
425 if not similar_candidates:
426 logger.error('(no similar candidates)')
427 return
428 for candidate in similar_candidates:
429 logger.error(' %s', candidate)
Zheng-Jie Chang19ffc162021-03-31 15:17:52 +0800430
431
432def dict_get(element, *args):
433 """Recursively get a deep attribute in dict.
434
435 Args:
436 element: A dict element or None.
437 args: Attributes wanted to get.
438
439 Returns:
440 An attribute or None if attribute does not exist.
441 """
442 for arg in args:
443 if element is None:
444 break
445 element = element.get(arg)
446 return element