blob: 052a3f4fda8de90eaa91e7cddc522d7049275248 [file] [log] [blame]
Dennis Kempin58d9a742014-05-08 18:34:59 -07001# Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4#
5""" This module provides various tools for writing cros developer tools.
6
7This includes tools for regular expressions, path processing, chromium os git
8repositories, shell commands, and user interaction via command line.
9"""
Harry Cutts0edf1572020-01-21 15:42:10 -080010
Harry Cutts69fc2be2020-01-22 18:03:21 -080011from __future__ import absolute_import
12from __future__ import division
Harry Cutts0edf1572020-01-21 15:42:10 -080013from __future__ import print_function
14
Dennis Kempin58d9a742014-05-08 18:34:59 -070015import os
16import re
17import shlex
18import shutil
19import subprocess
20import sys
21import time
22
23class Emerge(object):
24 """Provides tools for emerging and deploying packages to chromebooks."""
25
26 def __init__(self, board_variant):
27 self.cmd = "emerge-{}".format(board_variant)
28
29 def Emerge(self, package_name):
30 SafeExecute([self.cmd, package_name], verbose=True)
31
32 def Deploy(self, package_name, remote, emerge=True):
33 if emerge:
34 self.Emerge(package_name)
35 SafeExecute(["cros", "deploy", remote.ip, package_name], verbose=True)
36
37
38class RegexException(Exception):
39 """Describes a regular expression failure."""
40 def __init__(self, regex, string, match_type):
41 Exception.__init__(self)
42 self.regex = regex
43 self.string = string
44 self.match_type = match_type
45
46 def __str__(self):
47 capped = self.string
48 if len(capped) > 256:
49 capped = capped[1:251] + "[...]"
50 msg = "RequiredRegex \"{}\" failed to {} on:\n{}\n"
51 return msg.format(self.regex, self.match_type, capped)
52
53
54class RequiredRegex(object):
55 """Wrapper for regular expressions using exceptions.
56
57 Most regular expression calls in mttools are used for parsing and any
58 mismatches result in a program failure.
59 To reduce the amount of error checking done in place this wrapper throws
60 meaningful exceptions in case of mismatches.
61 """
62 cap_length = 30
63
64 def __init__(self, pattern):
65 self.pattern = pattern
66
67 def Search(self, string, must_succeed=True):
68 match = re.search(self.pattern, string)
69 return self.__Check(match, string, must_succeed, "search")
70
71 def Match(self, string, must_succeed=True):
72 match = re.match(self.pattern, string)
73 return self.__Check(match, string, must_succeed, "match")
74
75 def __Check(self, match, string, must_succeed, match_type):
76 if not match and must_succeed:
77 raise RegexException(self.pattern, string, match_type)
78 return match
79
80
81class Path(object):
82 """Wrapper for os.path functions enforcing absolute/real paths.
83
84 This wrapper helps processing file paths by enforcing paths to be
85 always absolute and with symlinks resolved. Being a class it also
86 allows a more compact syntax for common operations.
87 """
88 def __init__(self, path, *args):
89 if isinstance(path, Path):
90 self.path = path.path
91 else:
92 self.path = os.path.abspath(path)
93
94 for arg in args:
95 self.path = os.path.join(self.path, str(arg))
96
97 def ListDir(self):
98 for entry in os.listdir(self.path):
99 yield Path(self, entry)
100
101 def Read(self):
102 return open(self.path, "r").read()
103
104 def Open(self, props):
105 return open(self.path, props)
106
107 def Write(self, value):
108 self.Open("w").write(value)
109
110 def Join(self, other):
111 return Path(os.path.join(self.path, other))
112
113 def CopyTo(self, target):
114 shutil.copy(self.path, str(target))
115
116 def MoveTo(self, target):
117 target = Path(target)
118 shutil.move(self.path, str(target))
119
120 def RelPath(self, rel=os.curdir):
121 return os.path.relpath(self.path, str(rel))
122
123 def RmTree(self):
124 return shutil.rmtree(self.path)
125
126 def MakeDirs(self):
127 if not self.exists:
128 os.makedirs(self.path)
129
130 @property
131 def parent(self):
132 return Path(os.path.dirname(self.path))
133
134 @property
135 def exists(self):
136 return os.path.exists(self.path)
137
138 @property
139 def is_file(self):
140 return os.path.isfile(self.path)
141
142 @property
143 def is_link(self):
144 return os.path.islink(self.path)
145
146 @property
147 def is_dir(self):
148 return os.path.isdir(self.path)
149
150 @property
151 def basename(self):
152 return os.path.basename(self.path)
153
154 def __eq__(self, other):
155 return self.path == other.path
156
157 def __ne__(self, other):
158 return self.path != other.path
159
160 def __div__(self, other):
161 return self.Join(other)
162
Harry Cutts69fc2be2020-01-22 18:03:21 -0800163 def __truediv__(self, other):
164 return self.Join(other)
165
Dennis Kempin58d9a742014-05-08 18:34:59 -0700166 def __bool__(self):
167 return self.exists
168
169 def __str__(self):
170 return self.path
171
172
173class ExecuteException(Exception):
174 """Describes a failure to run a shell command."""
175 def __init__(self, command, code, out, verbose=False):
176 Exception.__init__(self)
177 self.command = command
178 self.code = code
179 self.out = out
180 self.verbose = verbose
181
182 def __str__(self):
183 string = "$ %s\n" % " ".join(self.command)
184 if self.out:
185 string = string + str(self.out) + "\n"
186 string = string + "Command returned " + str(self.code)
187 return string
188
189
190def Execute(command, cwd=None, verbose=False, interactive=False,
191 must_succeed=False):
192 """Execute shell command.
193
194 Returns false if the command failed (i.e. return code != 0), otherwise
195 this command returns the stdout/stderr output of the command.
196 The verbose flag will print the executed command.
197 The interactive flag will not capture stdout/stderr, but allow direct
198 interaction with the command.
199 """
200 if must_succeed:
201 return SafeExecute(command, cwd, verbose, interactive)
202 (code, out) = __Execute(command, cwd, verbose, interactive)
203 if code != 0:
204 return False
205 return out
206
207
208def SafeExecute(command, cwd=None, verbose=False, interactive=False):
209 """Execute shell command and throw exception upon failure.
210
211 This method behaves the same as Execute, but throws an ExecuteException
212 if the command fails.
213 """
214 if isinstance(command, basestring):
215 command = shlex.split(command)
216 (code, out) = __Execute(command, cwd, verbose, interactive)
217 if code != 0:
218 raise ExecuteException(command, code, out, verbose)
219 return out
220
221def __Execute(command, cwd=None, verbose=False, interactive=False):
222 if isinstance(command, basestring):
223 command = shlex.split(command)
224
225 if cwd:
226 cwd = str(cwd)
227
228 if verbose:
Harry Cutts0edf1572020-01-21 15:42:10 -0800229 print("$", " ".join(command))
Dennis Kempin58d9a742014-05-08 18:34:59 -0700230
231 if interactive:
232 process = subprocess.Popen(command, cwd=cwd)
233 else:
234 process = subprocess.Popen(command,
235 stdout=subprocess.PIPE,
236 stderr=subprocess.STDOUT,
237 cwd=cwd)
Dennis Kempin7e296e92014-05-30 10:07:18 -0700238 if verbose and not interactive:
Dennis Kempin58d9a742014-05-08 18:34:59 -0700239 # print a . approximately every second to show progress
240 seconds = 0
241 while process.poll() is None:
242 seconds += 0.01
243 time.sleep(0.01)
244 if seconds > 1:
245 seconds = seconds - 1
246 sys.stdout.write(".")
247 sys.stdout.flush()
Harry Cutts0edf1572020-01-21 15:42:10 -0800248 print("")
Dennis Kempin58d9a742014-05-08 18:34:59 -0700249 else:
250 process.wait()
251
252 out = None
253 if not interactive:
254 out = process.stdout.read().replace("\r", "").strip()
255
256 return (process.returncode, out)
257
258class GitRepo(object):
259 """A helper class to work with Git repositories.
260
261 This class is specialized to deal with chromium specific git workflows
262 and uses the git command line program to interface with the repository.
263 """
264 def __init__(self, repo_path, verbose=False):
265 self.path = Path(repo_path)
266 self.verbose = verbose
267 rel_path = self.path.RelPath("/mnt/host/source/src/")
268 self.review_url = ("https://chrome-internal.googlesource.com/chromeos/" +
269 rel_path)
270
271 def SafeExecute(self, command):
272 if isinstance(command, str):
273 command = "git " + command
274 else:
275 command = ["git"] + command
276 return SafeExecute(command, cwd=self.path, verbose=self.verbose)
277
278 def Execute(self, command):
279 if isinstance(command, str):
280 command = "git " + command
281 else:
282 command = ["git"] + command
283 return Execute("git " + command, cwd=self.path, verbose=self.verbose)
284
285 @property
286 def active_branch(self):
287 result = self.SafeExecute("branch")
288
289 regex = "\\* (\\S+)"
290 match = re.search(regex, result)
291 if match:
292 return match.group(1)
293 return None
294
295 @property
296 def branches(self):
297 branches = []
298 result = self.SafeExecute("branch")
299 regex = "(\\S+)$"
300 for match in re.finditer(regex, result):
301 branches.append(match.group(1))
Harry Cutts0edf1572020-01-21 15:42:10 -0800302 print("Branches:", branches)
Dennis Kempin58d9a742014-05-08 18:34:59 -0700303 return branches
304
305 @property
306 def working_directory_dirty(self):
307 result = self.SafeExecute("status")
308 return "Changes not staged for commit" in result
309
310 @property
311 def index_dirty(self):
312 result = self.SafeExecute("status")
313 return "Changes to be committed" in result
314
315 @property
316 def diverged(self):
317 result = self.SafeExecute("status")
318 return "Your branch is ahead of" in result
319
320 @property
321 def remote(self):
322 return self.SafeExecute("remote").strip()
323
324 @property
325 def status(self):
326 status = []
327 if self.index_dirty:
328 status.append("changes in index")
329 if self.diverged:
330 status.append("diverged from master")
331 if self.working_directory_dirty:
332 status.append("local changes")
333 if not status:
334 status.append("clean")
335 return ", ".join(status)
336
337 def Move(self, source, destination):
338 source = Path(source).RelPath(self.path)
339 destination = Path(destination).RelPath(self.path)
340 self.SafeExecute(["mv", source, destination])
341
342 def DeleteBranch(self, branch_name):
343 if branch_name not in self.branches:
344 return
345 self.SafeExecute("checkout -f m/master")
346 self.SafeExecute("branch -D " + branch_name)
347
348 def CreateBranch(self, branch_name, tracking=None):
349 if tracking:
350 self.SafeExecute("checkout -b " + branch_name + " " + tracking)
351 else:
352 self.SafeExecute("checkout -b " + branch_name)
353
354 def Checkout(self, name, force=False):
355 cmd = "checkout " + name
356 if force:
357 cmd = cmd + " -f"
358 self.SafeExecute(cmd)
359
360 def Stash(self):
361 self.SafeExecute("stash")
362
363 def Add(self, filepath):
364 self.SafeExecute(["add", Path(filepath).RelPath(self.path)])
365
366 def Commit(self, message, ammend=False, all_changes=False,
367 ammend_if_diverged=False):
368 cmd = "commit"
369 if ammend or (ammend_if_diverged and self.diverged):
370 existing = self.SafeExecute("log --format=%B -n 1")
371 regex = "Change-Id: I[a-f0-9]+"
372 match = re.search(regex, existing)
373 if match:
374 message = message + "\n" + match.group(0)
375 cmd = cmd + " --amend"
376 if all_changes:
377 cmd = cmd + " -a"
378 cmd = cmd + " -m \"" + message + "\""
379 self.SafeExecute(cmd)
380
381 def Upload(self):
382 result = self.SafeExecute("push %s HEAD:refs/for/master" % self.remote)
Dennis Kempin76f89c72014-05-15 15:53:41 -0700383 if "error" in result or "Error" in result:
384 raise Exception("Failed to upload:\n{}".format(result))
Dennis Kempin58d9a742014-05-08 18:34:59 -0700385 regex = "https://[a-zA-Z0-9\\-\\./]+"
386 match = re.search(regex, result)
387 if match:
388 return match.group(0)
389 return None
390
391class AskUser(object):
392 """Various static methods to ask for user input."""
393
394 @staticmethod
395 def Text(message=None, validate=None, default=None):
396 """Ask user to input a text."""
397 while True:
398 if message:
Harry Cutts0edf1572020-01-21 15:42:10 -0800399 print(message)
Dennis Kempin58d9a742014-05-08 18:34:59 -0700400 reply = sys.stdin.readline().strip()
401 if len(reply) == 0:
402 return default
403 if validate:
404 try:
405 reply = validate(reply)
Harry Cutts69fc2be2020-01-22 18:03:21 -0800406 except Exception as e:
Harry Cutts0edf1572020-01-21 15:42:10 -0800407 print(e)
Dennis Kempin58d9a742014-05-08 18:34:59 -0700408 continue
409 return reply
410
411 @staticmethod
412 def Continue():
413 """Ask user to press enter to continue."""
414 AskUser.Text("Press enter to continue.")
415
416 @staticmethod
417 def YesNo(message, default=False):
418 """Ask user to reply with yes or no."""
419 if default is True:
Harry Cutts0edf1572020-01-21 15:42:10 -0800420 print(message, "[Y/n]")
Dennis Kempin58d9a742014-05-08 18:34:59 -0700421 else:
Harry Cutts0edf1572020-01-21 15:42:10 -0800422 print(message, "[y/N]")
Dennis Kempin58d9a742014-05-08 18:34:59 -0700423 choice = sys.stdin.readline().strip()
424 if choice in ("y", "Y", "Yes", "yes"):
425 return True
426 elif choice in ("n", "N", "No", "no"):
427 return False
428 return default
429
430 @staticmethod
431 def Select(choices, msg, allow_none=False):
432 """Ask user to make a single choice from a list.
433
434 Returns the index of the item selected by the user.
435
436 allow_none allows the user to make an empty selection, which
437 will return None.
438
439 Note: Both None and the index 0 will evaluate to boolean False,
440 check the value explicitly with "if selection is None".
441 """
442 selection = AskUser.__Select(choices, msg, False, allow_none)
443 if len(selection) == 0:
444 return None
445 return selection[0]
446
447 @staticmethod
448 def SelectMulti(choices, msg, allow_none=False):
449 """Ask user to make a multiple choice from a list.
450
451 Returns the list of indices selected by the user.
452
453 allow_none allows the user to make an empty selection, which
454 will return an empty list.
455 """
456 return AskUser.__Select(choices, msg, True, allow_none)
457
458 @staticmethod
459 def __Select(choices, msg, allow_multi=False, allow_none=False):
460 # skip selection if there is only one option
461 if len(choices) == 1 and not allow_none:
462 return [0]
463
464 # repeat until user made a valid selection
465 while True:
466 # get user input (displays index + 1).
Harry Cutts0edf1572020-01-21 15:42:10 -0800467 print(msg)
Dennis Kempin58d9a742014-05-08 18:34:59 -0700468 for i, item in enumerate(choices):
Harry Cutts0edf1572020-01-21 15:42:10 -0800469 print(" ", str(i + 1) + ":", item)
Dennis Kempin58d9a742014-05-08 18:34:59 -0700470 if allow_multi:
Harry Cutts0edf1572020-01-21 15:42:10 -0800471 print("(Separate multiple selections with spaces)")
Dennis Kempin58d9a742014-05-08 18:34:59 -0700472 if allow_none:
Harry Cutts0edf1572020-01-21 15:42:10 -0800473 print("(Press enter to select none)")
Dennis Kempin58d9a742014-05-08 18:34:59 -0700474
475 sys.stdout.write('> ')
476 sys.stdout.flush()
477 selection = sys.stdin.readline()
478
479 if len(selection.strip()) == 0 and allow_none:
480 return []
481
482 if allow_multi:
483 selections = selection.split(" ")
484 else:
485 selections = [selection]
486
487 # validates single input value
488 def ProcessSelection(selection):
489 try:
490 idx = int(selection) - 1
491 if idx < 0 or idx >= len(choices):
Harry Cutts0edf1572020-01-21 15:42:10 -0800492 print("Number out of range")
Dennis Kempin58d9a742014-05-08 18:34:59 -0700493 return None
494 except ValueError:
Harry Cutts0edf1572020-01-21 15:42:10 -0800495 print("Not a number")
Dennis Kempin58d9a742014-05-08 18:34:59 -0700496 return None
497 return idx
498
499 # validate list of values
500 selections = [ProcessSelection(s) for s in selections]
501 if None not in selections:
502 return selections
503