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