blob: e1ed7b6f27e145f3340dc540b7c8a808114636ab [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 Wuaf917102020-04-10 18:03:22 +080010import re
Kuang-che Wu88875db2017-07-20 10:47:53 +080011import subprocess
12import threading
13import time
14
15import psutil
Kuang-che Wu68f022d2019-11-29 14:38:48 +080016import six
Kuang-che Wuae6824b2019-08-27 22:20:01 +080017from six.moves import queue
Kuang-che Wu88875db2017-07-20 10:47:53 +080018
Kuang-che Wu44278142019-03-04 11:33:57 +080019from bisect_kit import errors
20
Kuang-che Wu88875db2017-07-20 10:47:53 +080021logger = logging.getLogger(__name__)
22
23
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +080024class TimeoutExpired(Exception):
25 """Timeout expired.
26
27 This may be raised by blocking calls like Popen.wait(), check_call(),
28 check_output(), etc.
29 """
30
31
Kuang-che Wu23192ad2020-03-11 18:12:46 +080032class Popen:
Kuang-che Wu88875db2017-07-20 10:47:53 +080033 """Wrapper of subprocess.Popen. Support output logging.
34
Kuang-che Wu68f022d2019-11-29 14:38:48 +080035 The default is text mode with utf8 encoding. This is different to
36 subprocess.Popen, which is default binary.
37
Kuang-che Wu88875db2017-07-20 10:47:53 +080038 Attributes:
39 duration: Wall time of program execution in seconds.
40 returncode: The child return code.
41 """
42
43 def __init__(self,
44 args,
45 stdout_callback=None,
46 stderr_callback=None,
Kuang-che Wubcafc552019-08-15 15:27:02 +080047 log_stdout=True,
Kuang-che Wu68f022d2019-11-29 14:38:48 +080048 binary=None,
Kuang-che Wu88875db2017-07-20 10:47:53 +080049 **kwargs):
50 """Initializes Popen.
51
52 Args:
53 args: Command line arguments.
54 stdout_callback: Callback function for stdout. Called once per line.
55 stderr_callback: Callback function for stderr. Called once per line.
Kuang-che Wu68f022d2019-11-29 14:38:48 +080056 binary: binary mode; default is False
Kuang-che Wubcafc552019-08-15 15:27:02 +080057 log_stdout: Whether write the stdout output of the child process to log.
Kuang-che Wu88875db2017-07-20 10:47:53 +080058 **kwargs: Additional arguments passing to subprocess.Popen.
59 """
60 if 'stdout' in kwargs:
61 raise ValueError('stdout argument not allowed, it will be overridden.')
62 if 'stderr' in kwargs:
63 raise ValueError('stderr argument not allowed, it will be overridden.')
64
Kuang-che Wu68f022d2019-11-29 14:38:48 +080065 if binary:
66 assert not kwargs.get('encoding')
67 self.binary_mode = True
68 self.encoding = None
69 self.log_stdout = False
70 else:
71 self.binary_mode = False
72 self.encoding = kwargs.get('encoding', 'utf8')
73 self.log_stdout = log_stdout
74
75 if six.PY2:
76 if 'encoding' in kwargs:
77 del kwargs['encoding']
78 else:
79 kwargs['encoding'] = self.encoding
80
Kuang-che Wu88875db2017-07-20 10:47:53 +080081 self.stdout_callback = stdout_callback
82 self.stderr_callback = stderr_callback
Kuang-che Wu88875db2017-07-20 10:47:53 +080083 self.stdout_lines = []
84 self.stderr_lines = []
85 self.duration = -1
86 self.start = time.time()
Kuang-che Wuae6824b2019-08-27 22:20:01 +080087 self.queue = queue.Queue(65536)
Kuang-che Wu88875db2017-07-20 10:47:53 +080088 if isinstance(args, str):
89 logger.debug('cwd=%s, run %r', kwargs.get('cwd'), args)
90 else:
Kuang-che Wu62dda1e2018-03-27 20:37:52 +080091 logger.debug('cwd=%s, run %r', kwargs.get('cwd'),
92 subprocess.list2cmdline(args))
Kuang-che Wu88875db2017-07-20 10:47:53 +080093 self.p = subprocess.Popen(
94 args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
95
96 self.stdout_thread = threading.Thread(
97 target=self._reader_thread, args=('stdout', self.p.stdout))
98 self.stdout_thread.setDaemon(True)
99 self.stdout_thread.start()
100
101 self.stderr_thread = threading.Thread(
102 target=self._reader_thread, args=('stderr', self.p.stderr))
103 self.stderr_thread.setDaemon(True)
104 self.stderr_thread.start()
105
106 @property
107 def returncode(self):
108 return self.p.returncode
109
110 def _reader_thread(self, where, child_file):
111 """Reader thread to help reading stdout and stderr.
112
113 Args:
114 where: 'stdout' or 'stderr'.
115 child_file: file object which producing output.
116 """
117 for line in iter(child_file.readline, ''):
118 self.queue.put((where, line))
119 self.queue.put((where, ''))
Kuang-che Wu74bcb642020-02-20 18:45:53 +0800120 child_file.close()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800121
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800122 def wait(self, timeout=None):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800123 """Waits child process.
124
125 Returns:
126 return code.
127 """
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800128 t0 = time.time()
Kuang-che Wu88875db2017-07-20 10:47:53 +0800129 ended = 0
130 while ended < 2:
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800131 if timeout is not None:
132 try:
133 remaining_time = timeout - (time.time() - t0)
134 if remaining_time > 0:
135 where, line = self.queue.get(block=True, timeout=remaining_time)
136 else:
137 # We follow queue.get's behavior to raise queue.Empty, so it's
138 # always queue.Empty when time is up, no matter remaining_time is
139 # negative or positive.
140 raise queue.Empty
141 except queue.Empty:
142 logger.debug('child process time out (%.1f seconds), kill it',
143 timeout)
144 self.p.kill()
145 raise TimeoutExpired
146 else:
147 where, line = self.queue.get(block=True)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800148 # line includes '\n', will be '' if EOF.
149 if not line:
150 ended += 1
151 continue
Kuang-che Wu88875db2017-07-20 10:47:53 +0800152 if self.stdout_callback and where == 'stdout':
153 self.stdout_callback(line)
154 if self.stderr_callback and where == 'stderr':
155 self.stderr_callback(line)
Kuang-che Wu68f022d2019-11-29 14:38:48 +0800156 if self.log_stdout or where == 'stderr':
157 if self.binary_mode:
158 line = line.decode('utf8', errors='replace')
159 logger.debug('[%s] %s', where, line.rstrip('\n'))
Kuang-che Wu88875db2017-07-20 10:47:53 +0800160 self.p.wait()
161 self.duration = time.time() - self.start
162 logger.debug('returncode %d', self.returncode)
163 return self.returncode
164
165 def terminate(self):
166 """Terminates child and descendant processes."""
167 # Need to ignore failures because sometimes they are expected.
168 # For example, the owner of child process is different to current and
169 # unable to be killed by current process. 'cros_sdk' is one of such case.
Kuang-che Wu455c7342017-11-28 13:21:32 +0800170 for proc in psutil.Process(self.p.pid).children(recursive=True):
Kuang-che Wu88875db2017-07-20 10:47:53 +0800171 try:
172 proc.terminate()
Kuang-che Wu68db08a2018-03-30 11:50:34 +0800173 except psutil.AccessDenied:
Kuang-che Wu88875db2017-07-20 10:47:53 +0800174 logger.warning('Unable to terminate pid=%d; ignore', proc.pid)
175 try:
176 self.p.terminate()
177 except OSError:
178 logger.warning('Unable to terminate pid=%d; ignore', self.p.pid)
179 time.sleep(0.1)
180 try:
181 self.p.kill()
182 except OSError:
183 logger.warning('Unable to kill pid=%d; ignore', self.p.pid)
184
185
186def call(*args, **kwargs):
187 """Run command.
188
189 Modeled after subprocess.call.
190
191 Returns:
192 Exit code of sub-process.
193 """
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800194 timeout = kwargs.get('timeout')
195 # TODO(kcwu): let current function capture this optional parameter after
196 # migrated to python3
197 if 'timeout' in kwargs:
198 del kwargs['timeout']
Kuang-che Wu88875db2017-07-20 10:47:53 +0800199 p = Popen(args, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800200 return p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800201
202
203def check_output(*args, **kwargs):
204 """Runs command and return output.
205
206 Modeled after subprocess.check_output.
207
208 Returns:
209 stdout string of execution.
210
211 Raises:
212 subprocess.CalledProcessError if the exit code is non-zero.
213 """
214 stdout_lines = []
215
216 def collect_stdout(line):
217 stdout_lines.append(line)
218
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800219 timeout = kwargs.get('timeout')
220 # TODO(kcwu): let current function capture this optional parameter after
221 # migrated to python3
222 if 'timeout' in kwargs:
223 del kwargs['timeout']
Kuang-che Wu88875db2017-07-20 10:47:53 +0800224 p = Popen(args, stdout_callback=collect_stdout, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800225 p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800226 stdout = ''.join(stdout_lines)
227 if p.returncode != 0:
228 raise subprocess.CalledProcessError(p.returncode, args, stdout)
229
230 return stdout
231
232
233def check_call(*args, **kwargs):
234 """Runs command and ensures it succeeded.
235
236 Modeled after subprocess.check_call.
237
238 Raises:
239 subprocess.CalledProcessError if the exit code is non-zero.
240 """
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800241 timeout = kwargs.get('timeout')
242 # TODO(kcwu): let current function capture this optional parameter after
243 # migrated to python3
244 if 'timeout' in kwargs:
245 del kwargs['timeout']
Kuang-che Wu88875db2017-07-20 10:47:53 +0800246 p = Popen(args, **kwargs)
Kuang-che Wuc26dcdf2019-11-01 16:30:06 +0800247 p.wait(timeout=timeout)
Kuang-che Wu88875db2017-07-20 10:47:53 +0800248 if p.returncode != 0:
249 raise subprocess.CalledProcessError(p.returncode, args)
250
251
Kuang-che Wu44278142019-03-04 11:33:57 +0800252def ssh_cmd(host, *args, **kwargs):
253 """Runs remote command using ssh.
254
255 Args:
256 host: remote host address
257 args: command and args running on the remote host
258 kwargs:
259 connect_timeout: connection timeout in seconds (int)
260
261 Raises:
262 subprocess.CalledProcessError if the exit code is non-zero.
263 """
264 cmd = ['ssh']
265 if kwargs.get('connect_timeout'):
266 cmd += ['-oConnectTimeout=%d' % kwargs['connect_timeout']]
267 cmd.append(host)
268 cmd += list(args)
269 try:
270 return check_output(*cmd)
271 except subprocess.CalledProcessError as e:
272 # ssh's own error code is 255.
273 if e.returncode == 255:
274 raise errors.SshConnectionError('ssh connection to %r failed' % host)
275 raise
276
277
Kuang-che Wuaf917102020-04-10 18:03:22 +0800278def escape_rev(rev):
279 """Escapes special characters in version string.
280
281 Sometimes we save files whose name is related to version, e.g. cache file and
282 log file. Version strings must be escaped properly in order to make them
283 path-friendly.
284
285 Args:
286 rev: rev string
287
288 Returns:
289 escaped string
290 """
291 # TODO(kcwu): change infra rev format, avoid special characters
292 # Assume they don't collision after escaping.
293 # Don't use "#" because gsutil using it as version identifiers.
294 return re.sub('[^a-zA-Z0-9~._-]', '_', rev)
295
296
Kuang-che Wu88875db2017-07-20 10:47:53 +0800297def version_key_func(v):
298 """Splits version string into components.
299
300 Split version number by '.', and convert to `int` if possible. After this
301 conversion, version numbers can be compared ordering directly. Usually this is
302 used with sort function together.
303
304 Example,
305 >>> version_key_func('1.a.3')
306 [1, 'a', 3]
307
308 Args:
309 v: version string
310
311 Returns:
312 list of int or string
313 """
314 return [int(x) if x.isdigit() else x for x in v.split('.')]
315
316
317def is_version_lesseq(a, b):
318 """Compares whether version `a` is less or equal to version `b`.
319
320 Note this only compares the numeric values component-wise. That is, '1.1' is
321 less than '2.0', but '1.1' may or may not be older than '2.0' according to
322 chromium version semantic.
323
324 Args:
325 a: version string
326 b: version string
327
328 Returns:
329 bool: True if a <= b
330 """
331 return version_key_func(a) <= version_key_func(b)
332
333
334def is_direct_relative_version(a, b):
335 r"""Determines two versions are direct-relative.
336
337 "Direct-relative" means "one is ancestor of the other".
338
339 This follows chromium and chromiumos version semantic.
340 https://www.chromium.org/developers/version-numbers
341
342 That is, [Major+1].[Minor] is a descendant of [Major+1].1, which is branched
343 from [Major+1].0, which is a child of [Major].0. Thus, [Major+1].[Minor] is
344 not direct-relative to any [Major].[Minor>0].
345
346 For example, in this chart, 3.3 is not direct-relative to 2.2.
347
348 -> 2.0 ------------------> 3.0 -------------
349 \ \
350 -> 2.1 -> 2.2 .... -> 3.1 -> 3.2 -> 3.3 ....
351
352 Args:
353 a: version string
354 b: version string
355
356 Returns:
357 bool: True if `a` and `b` are direct-relative.
358 """
359 a = version_key_func(a)
360 b = version_key_func(b)
361 assert len(a) == len(b)
362 if a > b:
363 a, b = b, a
364
365 branched = False
366 for x, y in zip(a, b):
367 if branched:
368 if x != 0:
369 return False
370 elif x != y:
371 branched = True
372
373 return True
Kuang-che Wu11713052019-05-30 16:21:54 +0800374
375
376def show_similar_candidates(key, value, candidates):
377 logger.error('incorrect %s: %r; possible candidates:', key, value)
378 if not candidates:
379 logger.error('(no candidates at all)')
380 return
381 similar_candidates = difflib.get_close_matches(value, candidates)
382 if not similar_candidates:
383 logger.error('(no similar candidates)')
384 return
385 for candidate in similar_candidates:
386 logger.error(' %s', candidate)