blob: 2a1b75d3485634d533cbddef817f5d4635bae023 [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
Kuang-che Wuaf917102020-04-10 18:03:22 +0800275def escape_rev(rev):
276 """Escapes special characters in version string.
277
278 Sometimes we save files whose name is related to version, e.g. cache file and
279 log file. Version strings must be escaped properly in order to make them
280 path-friendly.
281
282 Args:
283 rev: rev string
284
285 Returns:
286 escaped string
287 """
288 # TODO(kcwu): change infra rev format, avoid special characters
289 # Assume they don't collision after escaping.
290 # Don't use "#" because gsutil using it as version identifiers.
291 return re.sub('[^a-zA-Z0-9~._-]', '_', rev)
292
293
Kuang-che Wu88875db2017-07-20 10:47:53 +0800294def version_key_func(v):
295 """Splits version string into components.
296
297 Split version number by '.', and convert to `int` if possible. After this
298 conversion, version numbers can be compared ordering directly. Usually this is
299 used with sort function together.
300
301 Example,
302 >>> version_key_func('1.a.3')
303 [1, 'a', 3]
304
305 Args:
306 v: version string
307
308 Returns:
309 list of int or string
310 """
311 return [int(x) if x.isdigit() else x for x in v.split('.')]
312
313
314def is_version_lesseq(a, b):
315 """Compares whether version `a` is less or equal to version `b`.
316
317 Note this only compares the numeric values component-wise. That is, '1.1' is
318 less than '2.0', but '1.1' may or may not be older than '2.0' according to
319 chromium version semantic.
320
321 Args:
322 a: version string
323 b: version string
324
325 Returns:
326 bool: True if a <= b
327 """
328 return version_key_func(a) <= version_key_func(b)
329
330
331def is_direct_relative_version(a, b):
332 r"""Determines two versions are direct-relative.
333
334 "Direct-relative" means "one is ancestor of the other".
335
336 This follows chromium and chromiumos version semantic.
337 https://www.chromium.org/developers/version-numbers
338
339 That is, [Major+1].[Minor] is a descendant of [Major+1].1, which is branched
340 from [Major+1].0, which is a child of [Major].0. Thus, [Major+1].[Minor] is
341 not direct-relative to any [Major].[Minor>0].
342
343 For example, in this chart, 3.3 is not direct-relative to 2.2.
344
345 -> 2.0 ------------------> 3.0 -------------
346 \ \
347 -> 2.1 -> 2.2 .... -> 3.1 -> 3.2 -> 3.3 ....
348
Kuang-che Wu430c5282021-01-27 21:10:25 +0800349 Note, one version is direct-relative to itself.
350
Kuang-che Wu88875db2017-07-20 10:47:53 +0800351 Args:
352 a: version string
353 b: version string
354
355 Returns:
356 bool: True if `a` and `b` are direct-relative.
357 """
358 a = version_key_func(a)
359 b = version_key_func(b)
360 assert len(a) == len(b)
361 if a > b:
362 a, b = b, a
363
364 branched = False
365 for x, y in zip(a, b):
366 if branched:
367 if x != 0:
368 return False
369 elif x != y:
370 branched = True
371
372 return True
Kuang-che Wu11713052019-05-30 16:21:54 +0800373
374
375def show_similar_candidates(key, value, candidates):
376 logger.error('incorrect %s: %r; possible candidates:', key, value)
377 if not candidates:
378 logger.error('(no candidates at all)')
379 return
380 similar_candidates = difflib.get_close_matches(value, candidates)
381 if not similar_candidates:
382 logger.error('(no similar candidates)')
383 return
384 for candidate in similar_candidates:
385 logger.error(' %s', candidate)
Zheng-Jie Chang19ffc162021-03-31 15:17:52 +0800386
387
388def dict_get(element, *args):
389 """Recursively get a deep attribute in dict.
390
391 Args:
392 element: A dict element or None.
393 args: Attributes wanted to get.
394
395 Returns:
396 An attribute or None if attribute does not exist.
397 """
398 for arg in args:
399 if element is None:
400 break
401 element = element.get(arg)
402 return element