blob: 17bd8ff781ed9753721d8057b7842184e12c286f [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()
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
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 Wu44278142019-03-04 11:33:57 +0800234def ssh_cmd(host, *args, **kwargs):
235 """Runs remote command using ssh.
236
237 Args:
238 host: remote host address
239 args: command and args running on the remote host
240 kwargs:
241 connect_timeout: connection timeout in seconds (int)
242
243 Raises:
244 subprocess.CalledProcessError if the exit code is non-zero.
245 """
246 cmd = ['ssh']
247 if kwargs.get('connect_timeout'):
248 cmd += ['-oConnectTimeout=%d' % kwargs['connect_timeout']]
249 cmd.append(host)
250 cmd += list(args)
251 try:
252 return check_output(*cmd)
253 except subprocess.CalledProcessError as e:
254 # ssh's own error code is 255.
255 if e.returncode == 255:
256 raise errors.SshConnectionError('ssh connection to %r failed' % host)
257 raise
258
259
Kuang-che Wuaf917102020-04-10 18:03:22 +0800260def escape_rev(rev):
261 """Escapes special characters in version string.
262
263 Sometimes we save files whose name is related to version, e.g. cache file and
264 log file. Version strings must be escaped properly in order to make them
265 path-friendly.
266
267 Args:
268 rev: rev string
269
270 Returns:
271 escaped string
272 """
273 # TODO(kcwu): change infra rev format, avoid special characters
274 # Assume they don't collision after escaping.
275 # Don't use "#" because gsutil using it as version identifiers.
276 return re.sub('[^a-zA-Z0-9~._-]', '_', rev)
277
278
Kuang-che Wu88875db2017-07-20 10:47:53 +0800279def version_key_func(v):
280 """Splits version string into components.
281
282 Split version number by '.', and convert to `int` if possible. After this
283 conversion, version numbers can be compared ordering directly. Usually this is
284 used with sort function together.
285
286 Example,
287 >>> version_key_func('1.a.3')
288 [1, 'a', 3]
289
290 Args:
291 v: version string
292
293 Returns:
294 list of int or string
295 """
296 return [int(x) if x.isdigit() else x for x in v.split('.')]
297
298
299def is_version_lesseq(a, b):
300 """Compares whether version `a` is less or equal to version `b`.
301
302 Note this only compares the numeric values component-wise. That is, '1.1' is
303 less than '2.0', but '1.1' may or may not be older than '2.0' according to
304 chromium version semantic.
305
306 Args:
307 a: version string
308 b: version string
309
310 Returns:
311 bool: True if a <= b
312 """
313 return version_key_func(a) <= version_key_func(b)
314
315
316def is_direct_relative_version(a, b):
317 r"""Determines two versions are direct-relative.
318
319 "Direct-relative" means "one is ancestor of the other".
320
321 This follows chromium and chromiumos version semantic.
322 https://www.chromium.org/developers/version-numbers
323
324 That is, [Major+1].[Minor] is a descendant of [Major+1].1, which is branched
325 from [Major+1].0, which is a child of [Major].0. Thus, [Major+1].[Minor] is
326 not direct-relative to any [Major].[Minor>0].
327
328 For example, in this chart, 3.3 is not direct-relative to 2.2.
329
330 -> 2.0 ------------------> 3.0 -------------
331 \ \
332 -> 2.1 -> 2.2 .... -> 3.1 -> 3.2 -> 3.3 ....
333
334 Args:
335 a: version string
336 b: version string
337
338 Returns:
339 bool: True if `a` and `b` are direct-relative.
340 """
341 a = version_key_func(a)
342 b = version_key_func(b)
343 assert len(a) == len(b)
344 if a > b:
345 a, b = b, a
346
347 branched = False
348 for x, y in zip(a, b):
349 if branched:
350 if x != 0:
351 return False
352 elif x != y:
353 branched = True
354
355 return True
Kuang-che Wu11713052019-05-30 16:21:54 +0800356
357
358def show_similar_candidates(key, value, candidates):
359 logger.error('incorrect %s: %r; possible candidates:', key, value)
360 if not candidates:
361 logger.error('(no candidates at all)')
362 return
363 similar_candidates = difflib.get_close_matches(value, candidates)
364 if not similar_candidates:
365 logger.error('(no similar candidates)')
366 return
367 for candidate in similar_candidates:
368 logger.error(' %s', candidate)